Functional Weave
Code in TypeScript

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 band
  • shippingRate(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
  • shippingRate(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
bandsShippingBand[]the merchant's rate card
servicestring
zonestring
parcelParcel
basketTotalMoneywhat counts towards free delivery, usually goods after discounts
onDatedatethe 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. */
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";
impl/typescript.ts · 76 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.

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
Download for TypeScript retail.shipping-rate-1.0.1-typescript.fune · 52,856 bytes sha256 40b617e57c135552a5e07cffb1e5233047786e486ae5808cb40b4a27f5e26d13

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.

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