Functional Weave
Code in Rust

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 band
  • shipping_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 threshold
  • shipping_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
bandsShippingBand[]the merchant's rate card
servicestring
zonestring
parcelParcel
basket_totalMoneywhat counts towards free delivery, usually goods after discounts
on_datedatethe order date, which decides the rate card in force
returnsShippingQuote

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(…)
impl/rust.rs · 183 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
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
Download for Rust retail.shipping-rate-1.0.1-rust.fune · 55,985 bytes sha256 bc823949099d936e82c1e55861c4e53b08cd98e1dd86d1e678746fae0cbd755b

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.

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

PathBytes
README.md2,629
impl/python.py3,096
impl/rust.rs5,728
impl/typescript.ts2,741
vectors.json37,254