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.
def shipping_rate(bands: Sequence[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
@dataclass(frozen=True)
class ShippingBand:
"""One row of a rate card: a weight band for one service and zone."""
service: str
zone: str
#: heaviest chargeable weight the band takes
max_grams: int
#: longest side allowed; null for no limit
max_length_mm: Optional[int]
#: cm3 per kg, e.g. 5000; null to charge on actual weight only
volumetric_divisor: Optional[int]
#: the band's billing step, 1 for none
round_up_to_grams: int
price: Money
#: delivery is free when basketTotal is at least this; null for never
free_over: Optional[Money]
valid_from: str
#: last day in force, inclusive; null while current
valid_to: Optional[str]
@dataclass(frozen=True)
class Parcel:
"""The packed parcel."""
length_mm: int
width_mm: int
height_mm: int
actual_grams: int
@dataclass(frozen=True)
class ShippingQuote:
"""The band that applies and what it costs."""
service: str
zone: str
#: the weight the band charged on
chargeable_grams: int
#: the band's upper limit, to show "up to 2 kg"
band_max_grams: int
#: the band's price before any free-delivery threshold
standard_price: Money
#: what the customer pays
price: Money
#: true when the free-over threshold was met
free: bool
Your code names it in one line, in the file that uses it
from fune.retail.shipping_rate import shipping_rate # retail.shipping-rate@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import re
from typing import Optional, Sequence
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 .money_compare import compare_money ← from money.compare ^1.0.0 · built alongside by fune
from .retail_volumetric_weight import chargeable_weight ← from retail.volumetric-weight ^1.0.0 · built alongside by fune
from .retail_shipping_rate_types import ShippingBand, Parcel, ShippingQuote
_ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
def _whole(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _chargeable_in(band: ShippingBand, parcel: Parcel) -> int:
if band.volumetric_divisor is None:
return round_div(parcel.actual_grams, band.round_up_to_grams, "up") * band.round_up_to_grams
return chargeable_weight(
parcel.length_mm,
parcel.width_mm,
parcel.height_mm,
parcel.actual_grams,
band.volumetric_divisor,
band.round_up_to_grams,
).chargeable_grams
def shipping_rate(
bands: Sequence[ShippingBand],
service: str,
zone: str,
parcel: Parcel,
basket_total: Money,
on_date: str,
) -> ShippingQuote:
"""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.
"""
if not isinstance(on_date, str) or not _ISO_DATE.fullmatch(on_date):
raise ValueError('onDate must be an ISO date (YYYY-MM-DD), received "%s"' % (on_date,))
for d in (parcel.length_mm, parcel.width_mm, parcel.height_mm):
if not _whole(d) or d < 1:
raise ValueError("dimensions must be 1 mm or more, received %r" % (d,))
if not _whole(parcel.actual_grams) or parcel.actual_grams < 0:
raise ValueError("actualGrams must not be negative, received %r" % (parcel.actual_grams,))
longest = max(parcel.length_mm, parcel.width_mm, parcel.height_mm)
best: Optional[ShippingBand] = None
best_grams = 0
for band in bands:
if band.service != service or band.zone != zone:
continue
if on_date < band.valid_from or (band.valid_to is not None and on_date > band.valid_to):
continue
if band.max_length_mm is not None and longest > band.max_length_mm:
continue
grams = _chargeable_in(band, parcel)
if grams > band.max_grams:
continue
if (
best is None
or band.max_grams < best.max_grams
or (band.max_grams == best.max_grams and compare_money(band.price, best.price) < 0)
):
best = band
best_grams = grams
if best is None:
raise ValueError(
'no shipping band for service "%s" to zone "%s" on %s fits this parcel' % (service, zone, on_date)
)
free = best.free_over is not None and compare_money(basket_total, best.free_over) >= 0
return ShippingQuote(
service=service,
zone=zone,
chargeable_grams=best_grams,
band_max_grams=best.max_grams,
standard_price=best.price,
price=money(0, best.price.currency) if free else best.price,
free=free,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 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.shipping-rate
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./retail.shipping-rate-1.0.1-python.fune, or fetch it from a terminal with fune pull retail.shipping-rate@1.0.1:python.
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 |