Functional Weave
Code in TypeScript

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 charged
  • roamingCharge(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
  • roamingCharge(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
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
onDatedatethe 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. */
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";
impl/typescript.ts · 57 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 { 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
Download for TypeScript telecoms.roaming-charge-1.0.1-typescript.fune · 18,324 bytes sha256 7a563701a218dd85314b302540092dfee3936ceacd85eff092eb1017ba854e06

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.

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 6 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
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

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