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
shippingRate(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 bandshippingRate(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 thresholdshippingRate(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.
export function shippingRate(bands: readonly ShippingBand[], service: string, zone: string, parcel: Parcel, basketTotal: Money, onDate: string): ShippingQuote
| bands | ShippingBand[] | the merchant's rate card |
| service | string | |
| zone | string | |
| parcel | Parcel | |
| basketTotal | Money | what counts towards free delivery, usually goods after discounts |
| onDate | 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. */
export interface ShippingBand {
readonly service: string;
readonly zone: string;
/** heaviest chargeable weight the band takes */
readonly maxGrams: number;
/** longest side allowed; null for no limit */
readonly maxLengthMm: number | null;
/** cm3 per kg, e.g. 5000; null to charge on actual weight only */
readonly volumetricDivisor: number | null;
/** the band's billing step, 1 for none */
readonly roundUpToGrams: number;
readonly price: Money;
/** delivery is free when basketTotal is at least this; null for never */
readonly freeOver: Money | null;
readonly validFrom: string;
/** last day in force, inclusive; null while current */
readonly validTo: string | null;
}
/** The packed parcel. */
export interface Parcel {
readonly lengthMm: number;
readonly widthMm: number;
readonly heightMm: number;
readonly actualGrams: number;
}
/** The band that applies and what it costs. */
export interface ShippingQuote {
readonly service: string;
readonly zone: string;
/** the weight the band charged on */
readonly chargeableGrams: number;
/** the band's upper limit, to show "up to 2 kg" */
readonly bandMaxGrams: number;
/** the band's price before any free-delivery threshold */
readonly standardPrice: Money;
/** what the customer pays */
readonly price: Money;
/** true when the free-over threshold was met */
readonly free: boolean;
}
Your code names it in one line, in the file that uses it
import { shippingRate } from "#fune/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 { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { compareMoney } from "./money_compare.ts"; ← from money.compare ^1.0.0 · built alongside by fune
import { chargeableWeight } from "./retail_volumetric_weight.ts"; ← from retail.volumetric-weight ^1.0.0 · built alongside by fune
import { type ShippingBand, type Parcel, type ShippingQuote } from "./retail_shipping_rate_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function chargeableIn(band: ShippingBand, parcel: Parcel): number {
if (band.volumetricDivisor === null) {
return roundDiv(parcel.actualGrams, band.roundUpToGrams, "up") * band.roundUpToGrams;
}
return chargeableWeight(
parcel.lengthMm,
parcel.widthMm,
parcel.heightMm,
parcel.actualGrams,
band.volumetricDivisor,
band.roundUpToGrams,
).chargeableGrams;
}
/**
* 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.
*/
export function shippingRate(
bands: readonly ShippingBand[],
service: string,
zone: string,
parcel: Parcel,
basketTotal: Money,
onDate: string,
): ShippingQuote {
if (!ISO_DATE.test(onDate)) throw new RangeError(`onDate must be an ISO date (YYYY-MM-DD), received "${onDate}"`);
for (const d of [parcel.lengthMm, parcel.widthMm, parcel.heightMm]) {
if (!Number.isInteger(d) || d < 1) throw new RangeError(`dimensions must be 1 mm or more, received ${d}`);
}
if (!Number.isInteger(parcel.actualGrams) || parcel.actualGrams < 0) {
throw new RangeError(`actualGrams must not be negative, received ${parcel.actualGrams}`);
}
const longest = Math.max(parcel.lengthMm, parcel.widthMm, parcel.heightMm);
let best: ShippingBand | null = null;
let bestGrams = 0;
for (const band of bands) {
if (band.service !== service || band.zone !== zone) continue;
if (onDate < band.validFrom || (band.validTo !== null && onDate > band.validTo)) continue;
if (band.maxLengthMm !== null && longest > band.maxLengthMm) continue;
const grams = chargeableIn(band, parcel);
if (grams > band.maxGrams) continue;
if (
best === null ||
band.maxGrams < best.maxGrams ||
(band.maxGrams === best.maxGrams && compareMoney(band.price, best.price) < 0)
) {
best = band;
bestGrams = grams;
}
}
if (best === null) {
throw new RangeError(`no shipping band for service "${service}" to zone "${zone}" on ${onDate} fits this parcel`);
}
const free = best.freeOver !== null && compareMoney(basketTotal, best.freeOver) >= 0;
return {
service,
zone,
chargeableGrams: bestGrams,
bandMaxGrams: best.maxGrams,
standardPrice: best.price,
price: free ? money(0, best.price.currency) : best.price,
free,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./retail.shipping-rate-1.0.1-typescript.fune, or fetch it from a terminal with fune pull retail.shipping-rate@1.0.1:typescript.
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 |