telecoms.roaming-charge
Roaming charges for a trip abroad from a dated tariff table of zones: daily fees, data, calls and texts.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 16 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 chargedroaming_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 feeroaming_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
| tariff | string | the tariff's name in the table; the table ships one illustrative tariff, ILLUSTRATIVE |
| country | string | where the phone was used, ISO 3166-1 alpha-2, uppercase: FR, US |
| on_date | date | the date of use, which decides the prices in force |
| usage | RoamingUsage | what was used in that country under that tariff |
| returns | RoamingCharge |
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(…)
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
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./telecoms.roaming-charge-1.0.1-rust.fune, or fetch it from a terminal with fune pull telecoms.roaming-charge@1.0.1:rust.
The whole function, every language, is one file too: telecoms.roaming-charge-1.0.1.fune, 26,410 bytes, sha256 d894624e3f41bf0cef0e6e79c70a1b3be68f6077aa0f0dfa1fe4546ddae16c8b. 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 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 |
| a trailing newline is not part of a country code | 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 |
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.
1.0.1 fixes Python accepting a trailing newline in country; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 2,382 |
| data/roaming-tariffs.json | 1,812 |
| impl/python.py | 3,230 |
| impl/rust.rs | 4,576 |
| impl/typescript.ts | 2,877 |
| vectors.json | 5,877 |