Functional Weave
Code in Rust

telecoms.roaming-charge

Roaming charges for a trip abroad from a dated tariff table of zones: daily fees, data, calls and texts.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 15 tests, run in TypeScript, Python and Rust.

What it does

Prices a trip abroad against a roaming tariff: which zone the country is in on that date, and what the usage there costs.

**The tariff in this package is illustrative. It is not any operator's tariff**, and its prices and dates (including the 2026 price rise in the EUROPE zone) are invented to exercise the calculation. It exists so the function, its vectors and its dated-data handling can be shown working. To bill real customers, publish your own version of this capability with your tariff rows in `data/roaming-tariffs.json`, or add rows under your own tariff name; the code does not change.

For example

  • roaming_charge(ILLUSTRATIVE, FR, 2026-07-10, days 7, data mb 3,000, call out minutes 60, call in minutes 20, sms out 15) → tariff ILLUSTRATIVE, zone EUROPE, daily fees £17.50, data £0.00, calls £0.00, sms £0.00, total £17.50 a week in France on a daily pass: only the daily fee is charged
  • roaming_charge(ILLUSTRATIVE, FR, 2025-07-10, days 7, data mb 3,000, call out minutes 60, call in minutes 20, sms out 15) → tariff ILLUSTRATIVE, zone EUROPE, daily fees £14.00, data £0.00, calls £0.00, sms £0.00, total £14.00 the same week a year earlier uses the older daily fee
  • roaming_charge(ILLUSTRATIVE, ES, 2025-12-31, days 1, data mb 10, call out minutes 0, call in minutes 0, sms out 0) → tariff ILLUSTRATIVE, zone EUROPE, daily fees £2.00, data £0.00, calls £0.00, sms £0.00, total £2.00 the last day of the old price

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 roaming_charge(tariff: &str, country: &str, on_date: &str, usage: &RoamingUsage) -> RoamingCharge
tariffstringthe tariff's name in the table; the table ships one illustrative tariff, ILLUSTRATIVE
countrystringwhere the phone was used, ISO 3166-1 alpha-2, uppercase: FR, US
on_datedatethe date of use, which decides the prices in force
usageRoamingUsagewhat was used in that country under that tariff
returnsRoamingCharge

The types it declares, generated into your project

/// Usage abroad, already totalled per country.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RoamingUsage {
    /// days on which the phone was used abroad; daily-fee zones charge per day
    pub days: i64,
    /// whole megabytes of data
    pub data_mb: i64,
    /// minutes of calls made, already rounded up per call
    pub call_out_minutes: i64,
    /// minutes of calls received, already rounded up per call
    pub call_in_minutes: i64,
    /// texts sent
    pub sms_out: i64,
}

/// The charge itemised as a bill prints it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoamingCharge {
    pub tariff: String,
    pub zone: String,
    pub daily_fees: Money,
    pub data: Money,
    /// calls made and received
    pub calls: Money,
    pub sms: Money,
    pub total: Money,
}

Your code names it in one line, in the file that uses it

fune!(telecoms.roaming-charge@^1);  // then call roaming_charge(…)
impl/rust.rs · 122 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::dates_add_days::epoch_day_from_iso;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_to_value};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune
use super::telecoms_roaming_charge_data::{RoamingRate, ROAMING_TARIFFS, ROAMING_TARIFFS_HISTORY};  ← this capability’s own data, compiled from data/roaming-tariffs.json into the same file by fune build

fn is_country(s: &str) -> bool {
    s.len() == 2 && s.bytes().all(|b| b.is_ascii_uppercase())
}

/// Price roaming usage in one country on one date: find the tariff's zone for
/// the country in force on that date, then multiply each kind of usage by its
/// price. Exact integer arithmetic throughout.
///
/// # Panics
/// Panics on a malformed country or date, negative usage, or when no zone of
/// the tariff covers the country on that date.
pub fn roaming_charge(tariff: &str, country: &str, on_date: &str, usage: &RoamingUsage) -> RoamingCharge {
    if !is_country(country) {
        panic!(
            "country must be an ISO 3166-1 alpha-2 code in capitals, received \"{}\"",
            country
        );
    }
    epoch_day_from_iso(on_date);
    let counts = [usage.days, usage.data_mb, usage.call_out_minutes, usage.call_in_minutes, usage.sms_out];
    if counts.iter().any(|&n| n < 0) {
        panic!("usage must be whole numbers of 0 or more");
    }

    let covers = |rule: &RoamingRate| rule.tariff == tariff && rule.countries.split(' ').any(|c| c == country);
    let mut best: Option<&RoamingRate> = None;
    for rule in ROAMING_TARIFFS {
        if !covers(rule) || on_date < rule.valid_from {
            continue;
        }
        if let Some(valid_to) = rule.valid_to {
            if on_date > valid_to {
                continue;
            }
        }
        if best.map_or(true, |b| rule.valid_from > b.valid_from) {
            best = Some(rule);
        }
    }

    let best = match best {
        Some(rule) => rule,
        None => {
            // A history=current build has dropped the rows that ended;
            // answering an older date with today's prices would be wrong, so
            // say what happened.
            if ROAMING_TARIFFS_HISTORY != "full" {
                let mut earliest: Option<&'static str> = None;
                for rule in ROAMING_TARIFFS {
                    if covers(rule) && earliest.map_or(true, |e| rule.valid_from < e) {
                        earliest = Some(rule.valid_from);
                    }
                }
                if let Some(earliest) = earliest {
                    if on_date < earliest {
                        panic!(
                            "no roaming zone for {} in tariff {} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full to price older usage.",
                            country, tariff, on_date, ROAMING_TARIFFS_HISTORY, earliest
                        );
                    }
                }
            }
            panic!("no roaming zone for {} in tariff {} on {}", country, tariff, on_date)
        }
    };

    let c = best.currency;
    let daily_fees = money(usage.days * best.daily_fee_minor, c);
    let data = money(usage.data_mb * best.data_per_mb_minor, c);
    let calls = money(
        usage.call_out_minutes * best.call_out_per_minute_minor + usage.call_in_minutes * best.call_in_per_minute_minor,
        c,
    );
    let sms = money(usage.sms_out * best.sms_out_minor, c);
    let total = sum_money(&[daily_fees.clone(), data.clone(), calls.clone(), sms.clone()], c);
    RoamingCharge {
        tariff: tariff.to_string(),
        zone: best.zone.to_string(),
        daily_fees,
        data,
        calls,
        sms,
        total,
    }
}

pub fn roaming_usage_from_value(v: &Value) -> RoamingUsage {
    RoamingUsage {
        days: v.get("days").as_i64(),
        data_mb: v.get("dataMb").as_i64(),
        call_out_minutes: v.get("callOutMinutes").as_i64(),
        call_in_minutes: v.get("callInMinutes").as_i64(),
        sms_out: v.get("smsOut").as_i64(),
    }
}

pub fn roaming_charge_to_value(r: &RoamingCharge) -> Value {
    Value::obj(vec![
        ("tariff", Value::str(&r.tariff)),
        ("zone", Value::str(&r.zone)),
        ("dailyFees", money_to_value(&r.daily_fees)),
        ("data", money_to_value(&r.data)),
        ("calls", money_to_value(&r.calls)),
        ("sms", money_to_value(&r.sms)),
        ("total", money_to_value(&r.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    roaming_charge_to_value(&roaming_charge(
        args[0].as_str(),
        args[1].as_str(),
        args[2].as_str(),
        &roaming_usage_from_value(&args[3]),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 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 telecoms.roaming-charge
Download for Rust telecoms.roaming-charge-1.0.0-rust.fune · 19,740 bytes sha256 9e7e8528458983eea180f51e5b7e4abc176c80d52803e059fd19df6036a20be0

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./telecoms.roaming-charge-1.0.0-rust.fune, or fetch it from a terminal with fune pull telecoms.roaming-charge@1.0.0:rust.

The whole function, every language, is one file too: telecoms.roaming-charge-1.0.0.fune, 26,052 bytes, sha256 bf9affc1d7cdd4b408cd9da8a81490bf46befb5b4fdac23ca86bc8a088e7daa9. 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 telecoms.roaming-charge

after — your function gets the result and the arguments, and returns the final result.

// fune: after telecoms.roaming-charge

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 dates.add-days in telecoms.roaming-charge
// fune: replace money.amount in telecoms.roaming-charge
// fune: replace money.sum in telecoms.roaming-charge

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 telecoms.roaming-charge --steps.

// fune: step telecoms.roaming-charge 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 week in France on a daily pass: only the daily fee is charged ILLUSTRATIVE, FR, 2026-07-10, days 7, data mb 3,000, call out minutes 60, call in minutes 20, sms out 15 → tariff ILLUSTRATIVE, zone EUROPE, daily fees £17.50, data £0.00, calls £0.00, sms £0.00, total £17.50
the same week a year earlier uses the older daily fee ILLUSTRATIVE, FR, 2025-07-10, days 7, data mb 3,000, call out minutes 60, call in minutes 20, sms out 15 → tariff ILLUSTRATIVE, zone EUROPE, daily fees £14.00, data £0.00, calls £0.00, sms £0.00, total £14.00
the last day of the old price ILLUSTRATIVE, ES, 2025-12-31, days 1, data mb 10, call out minutes 0, call in minutes 0, sms out 0 → tariff ILLUSTRATIVE, zone EUROPE, daily fees £2.00, data £0.00, calls £0.00, sms £0.00, total £2.00
the first day of the new price ILLUSTRATIVE, ES, 2026-01-01, days 1, data mb 10, call out minutes 0, call in minutes 0, sms out 0 → tariff ILLUSTRATIVE, zone EUROPE, daily fees £2.50, data £0.00, calls £0.00, sms £0.00, total £2.50
Norway is in the EEA, so in the European zone though not in the EU ILLUSTRATIVE, NO, 2026-08-01, days 1, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → tariff ILLUSTRATIVE, zone EUROPE, daily fees £2.50, data £0.00, calls £0.00, sms £0.00, total £2.50
pay as you go in the USA: data, calls both ways and texts ILLUSTRATIVE, US, 2026-05-01, days 5, data mb 200, call out minutes 12, call in minutes 30, sms out 10 → tariff ILLUSTRATIVE, zone WORLD-1, daily fees £0.00, data £100.00, calls £48.00, sms £4.00, total £152.00
Japan is in the dearer zone ILLUSTRATIVE, JP, 2026-05-01, days 3, data mb 50, call out minutes 3, call in minutes 0, sms out 2 → tariff ILLUSTRATIVE, zone WORLD-2, daily fees £0.00, data £150.00, calls £7.50, sms £1.00, total £158.50
a phone taken abroad but not used costs nothing ILLUSTRATIVE, US, 2026-05-01, days 0, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → tariff ILLUSTRATIVE, zone WORLD-1, daily fees £0.00, data £0.00, calls £0.00, sms £0.00, total £0.00
no days of use in a daily-pass zone costs nothing ILLUSTRATIVE, DE, 2026-05-01, days 0, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → tariff ILLUSTRATIVE, zone EUROPE, daily fees £0.00, data £0.00, calls £0.00, sms £0.00, total £0.00
use in the home country is not roaming and has no zone ILLUSTRATIVE, GB, 2026-07-10, days 1, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → error: no roaming zone for GB in tariff ILLUSTRATIVE on 2026-07-10
Show the other 5 tests
CaseArgumentsExpected
an unknown tariff has no zones ACME, FR, 2026-07-10, days 1, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → error: no roaming zone for FR in tariff ACME
a date before the tariff's first row is an error ILLUSTRATIVE, FR, 2023-06-01, days 1, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → error: no roaming zone for FR in tariff ILLUSTRATIVE on 2023-06-01
a lowercase country code is an error ILLUSTRATIVE, fr, 2026-07-10, days 1, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → error: country must be an ISO 3166-1 alpha-2 code
an impossible date is an error ILLUSTRATIVE, FR, 2026-02-30, days 1, data mb 0, call out minutes 0, call in minutes 0, sms out 0 → error: is not a real calendar date
negative usage is an error ILLUSTRATIVE, FR, 2026-07-10, days 1, data mb -5, call out minutes 0, call in minutes 0, sms out 0 → error: usage must be whole numbers of 0 or more

More from the author

A tariff is rows of zones. Each row names the countries in the zone (space-separated ISO 3166-1 alpha-2 codes), the currency, and five prices in minor units: a fee per day of use, and prices per MB of data, per minute of calls made, per minute of calls received and per text sent. A daily-pass zone has a daily fee and zero unit prices; a pay-as-you-go zone the opposite. The charge is simply

dailyFees = days x dailyFee data = dataMb x dataPerMb calls = callOutMinutes x callOutPerMinute + callInMinutes x callInPerMinute sms = smsOut x smsOut price total = the sum of the four

all exact, with no rounding. Minutes are whole minutes already rounded up per call (telecoms.call-rating does per-call rounding), and data is whole MB, so a usage record is priced by one country and one date at a time.

Rows are dated with `validFrom` and `validTo` (inclusive; null while in force). The row in force on `onDate` wins, and if two overlap the later `validFrom` wins. A project that installs with `history=current` keeps only the rows still in force; asking such a build about an older date fails with an explanation instead of silently pricing it at today's rates.

Home-country use, a country not in any zone of the tariff, an unknown tariff and a date before the tariff's first row are all errors ("no roaming zone"), because pricing unknown usage at zero is how roaming revenue leaks. Other errors: a country that is not two uppercase letters, a malformed or impossible date, and negative usage.

What this does not model: fair-use limits inside daily passes, spend caps (see telecoms.data-allowance), and per-country exceptions inside a zone.

Files

PathBytes
README.md2,309
data/roaming-tariffs.json1,812
impl/python.py3,228
impl/rust.rs4,576
impl/typescript.ts2,877
vectors.json5,624