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
roamingCharge(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 chargedroamingCharge(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 feeroamingCharge(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.
export function roamingCharge(tariff: string, country: string, onDate: string, 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 |
| onDate | 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. */
export interface RoamingUsage {
/** days on which the phone was used abroad; daily-fee zones charge per day */
readonly days: number;
/** whole megabytes of data */
readonly dataMb: number;
/** minutes of calls made, already rounded up per call */
readonly callOutMinutes: number;
/** minutes of calls received, already rounded up per call */
readonly callInMinutes: number;
/** texts sent */
readonly smsOut: number;
}
/** The charge itemised as a bill prints it. */
export interface RoamingCharge {
readonly tariff: string;
readonly zone: string;
readonly dailyFees: Money;
readonly data: Money;
/** calls made and received */
readonly calls: Money;
readonly sms: Money;
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { roamingCharge } from "#fune/telecoms.roaming-charge@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { epochDayFromIso } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { ROAMING_TARIFFS, ROAMING_TARIFFS_HISTORY } from "./telecoms_roaming_charge_data.ts"; ← this capability’s own data, compiled from data/roaming-tariffs.json into the same file by fune build
import { type RoamingCharge, type RoamingUsage } from "./telecoms_roaming_charge_types.ts";
const COUNTRY = /^[A-Z]{2}$/;
/**
* 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.
*/
export function roamingCharge(tariff: string, country: string, onDate: string, usage: RoamingUsage): RoamingCharge {
if (!COUNTRY.test(country)) {
throw new RangeError(`country must be an ISO 3166-1 alpha-2 code in capitals, received "${country}"`);
}
epochDayFromIso(onDate);
const counts = [usage.days, usage.dataMb, usage.callOutMinutes, usage.callInMinutes, usage.smsOut];
if (counts.some((n) => !Number.isInteger(n) || n < 0)) {
throw new RangeError("usage must be whole numbers of 0 or more");
}
const covers = (rule: (typeof ROAMING_TARIFFS)[number]) => rule.tariff === tariff && rule.countries.split(" ").includes(country);
let best: (typeof ROAMING_TARIFFS)[number] | null = null;
for (const rule of ROAMING_TARIFFS) {
if (!covers(rule)) continue;
if (onDate < rule.validFrom) continue;
if (rule.validTo !== null && onDate > rule.validTo) continue;
if (best === null || rule.validFrom > best.validFrom) best = rule;
}
if (best === null) {
// 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 earliest: string | null = null;
for (const rule of ROAMING_TARIFFS) {
if (covers(rule) && (earliest === null || rule.validFrom < earliest)) earliest = rule.validFrom;
}
if (earliest !== null && onDate < earliest) {
throw new RangeError(
`no roaming zone for ${country} in tariff ${tariff} on ${onDate}: this build was installed with history=${ROAMING_TARIFFS_HISTORY}, ` +
`so it only carries rules from ${earliest}. Reinstall with history=full to price older usage.`
);
}
}
throw new RangeError(`no roaming zone for ${country} in tariff ${tariff} on ${onDate}`);
}
const c = best.currency;
const dailyFees = money(usage.days * best.dailyFeeMinor, c);
const data = money(usage.dataMb * best.dataPerMbMinor, c);
const calls = money(usage.callOutMinutes * best.callOutPerMinuteMinor + usage.callInMinutes * best.callInPerMinuteMinor, c);
const sms = money(usage.smsOut * best.smsOutMinor, c);
return { tariff, zone: best.zone, dailyFees, data, calls, sms, total: sumMoney([dailyFees, data, calls, sms], c) };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 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 telecoms.roaming-charge
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./telecoms.roaming-charge-1.0.1-typescript.fune, or fetch it from a terminal with fune pull telecoms.roaming-charge@1.0.1:typescript.
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 |