Functional Weave
Code in Rust

telecoms.call-rating

Rate a call from a rate deck: longest-prefix destination match, billing increments, connection fee and minimum charge.

1.0.1 · published 2026-10-03 by charlie · Anterra

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

What it does

Rates one call record (CDR) against a rate deck the way telecoms billing does it, in four steps:

1. **Destination by longest prefix.** The dialled number is matched against every prefix in the deck and the longest match wins, so 447700900123 matches `447` (UK mobile) rather than `44` (UK fixed). Numbers are in international format, E.164 digits with or without a leading `+`; convert national numbers (07700 900123) before rating. No match is an error: silently rating an unknown destination at zero is how fraud traffic goes unbilled. 2. **Billing increments.** An increment is written first/next: 60/60 is per minute, 30/6 is a 30-second minimum then 6-second blocks, 1/1 is per second. The duration is rounded up to them: 61 seconds on 60/60 is 120 billed seconds; 125 seconds on 30/6 is 126. 3. **Charge.** perMinute x billedSeconds / 60, rounded once to a whole minor unit by the caller's `mode` (math.round-div), plus the connection fee. Operators differ on how they round fractions of a penny, so it is an argument rather than a hidden choice. 4. **Minimum charge.** If the result is below the minimum call charge, the minimum is charged. The minimum includes the connection fee.

For example

  • rate_call(+441632960001, 125, rates ×5, half-up) → prefix 44, destination United Kingdom fixed, billed seconds 180, usage charge £0.30, connection fee £0.00, total £0.30 a UK landline on 60/60: 125 seconds is billed as 3 minutes
  • rate_call(441632960001, 60, rates ×5, half-up) → prefix 44, destination United Kingdom fixed, billed seconds 60, usage charge £0.10, connection fee £0.00, total £0.10 exactly one minute on 60/60 is one minute
  • rate_call(441632960001, 61, rates ×5, half-up) → prefix 44, destination United Kingdom fixed, billed seconds 120, usage charge £0.20, connection fee £0.00, total £0.20 61 seconds on 60/60 is billed as two minutes

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 rate_call(dialled_number: &str, duration_seconds: i64, rates: &[CallRate], mode: &str) -> RatedCall
dialled_numberstringinternational format digits, E.164 with or without the leading +: 447700900123
duration_secondsintanswered duration in whole seconds; 0 for an unanswered call
ratesCallRate[]the rate deck; prefixes must be unique
modeRoundingModehow the per-minute charge rounds to a whole minor unit
returnsRatedCall

The types it declares, generated into your project

/// One row of a rate deck.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallRate {
    /// international digits the number starts with, without + or 00: 44, 447
    pub prefix: String,
    /// a name for invoices: United Kingdom mobile
    pub destination: String,
    /// price per 60 billed seconds
    pub per_minute: Money,
    /// added to every answered call; zero for none
    pub connection_fee: Money,
    /// the least an answered call costs, connection fee included; zero for none
    pub minimum_charge: Money,
    /// the first billed block: 60 in 60/60, 30 in 30/6, 1 in 1/1
    pub first_increment_seconds: i64,
    /// each later block: 60 in 60/60, 6 in 30/6, 1 in 1/1
    pub next_increment_seconds: i64,
}

/// The rated call, itemised as a bill prints it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RatedCall {
    /// the matched prefix
    pub prefix: String,
    pub destination: String,
    /// duration rounded up to the billing increments
    pub billed_seconds: i64,
    /// perMinute x billedSeconds / 60, rounded by mode
    pub usage_charge: Money,
    /// zero for an unanswered call
    pub connection_fee: Money,
    /// usage plus connection fee, raised to the minimum charge
    pub total: Money,
}

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

fune!(telecoms.call-rating@^1);  // then call rate_call(…)
impl/rust.rs · 112 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 std::collections::HashSet;

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::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value};  ← from money.amount ^1.0.0 · built alongside by fune

fn is_digits(s: &str) -> bool {
    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}

/// Rate one call: longest-prefix match on the dialled number, duration rounded
/// up to the rate's billing increments, per-minute charge rounded once by
/// `mode`, connection fee added and the minimum charge applied.
///
/// # Panics
/// Panics on a malformed number, a negative duration, a bad rate deck, no
/// matching prefix, or an unknown rounding mode.
pub fn rate_call(dialled_number: &str, duration_seconds: i64, rates: &[CallRate], mode: &str) -> RatedCall {
    let number = dialled_number.strip_prefix('+').unwrap_or(dialled_number);
    if !is_digits(number) {
        panic!(
            "dialled number must be international format digits, optionally with a leading +, received \"{}\"",
            dialled_number
        );
    }
    if duration_seconds < 0 {
        panic!("duration must be whole seconds of 0 or more, received {}", duration_seconds);
    }
    let mut seen: HashSet<&str> = HashSet::new();
    let mut best: Option<&CallRate> = None;
    for rate in rates {
        if !is_digits(&rate.prefix) {
            panic!("rate prefix must be digits, received \"{}\"", rate.prefix);
        }
        if !seen.insert(rate.prefix.as_str()) {
            panic!("duplicate prefix {} in the rate deck", rate.prefix);
        }
        if rate.first_increment_seconds < 1 || rate.next_increment_seconds < 1 {
            panic!(
                "billing increments must be 1 second or more, received {}/{} for prefix {}",
                rate.first_increment_seconds, rate.next_increment_seconds, rate.prefix
            );
        }
        assert_same_currency(&rates[0].per_minute, &rate.per_minute);
        assert_same_currency(&rates[0].per_minute, &rate.connection_fee);
        assert_same_currency(&rates[0].per_minute, &rate.minimum_charge);
        if number.starts_with(rate.prefix.as_str()) && best.map_or(true, |b| rate.prefix.len() > b.prefix.len()) {
            best = Some(rate);
        }
    }
    let best = match best {
        Some(rate) => rate,
        None => panic!("no rate for dialled number {}", number),
    };

    let currency = best.per_minute.currency.as_str();
    if duration_seconds == 0 {
        return RatedCall {
            prefix: best.prefix.clone(),
            destination: best.destination.clone(),
            billed_seconds: 0,
            usage_charge: money(0, currency),
            connection_fee: money(0, currency),
            total: money(0, currency),
        };
    }
    let first = best.first_increment_seconds;
    let step = best.next_increment_seconds;
    let billed_seconds = if duration_seconds <= first {
        first
    } else {
        first + ((duration_seconds - first + step - 1) / step) * step
    };
    let usage = round_div(best.per_minute.minor * billed_seconds, 60, mode);
    let total = (usage + best.connection_fee.minor).max(best.minimum_charge.minor);
    RatedCall {
        prefix: best.prefix.clone(),
        destination: best.destination.clone(),
        billed_seconds,
        usage_charge: money(usage, currency),
        connection_fee: best.connection_fee.clone(),
        total: money(total, currency),
    }
}

pub fn call_rate_from_value(v: &Value) -> CallRate {
    CallRate {
        prefix: v.get("prefix").as_str().to_string(),
        destination: v.get("destination").as_str().to_string(),
        per_minute: money_from_value(v.get("perMinute")),
        connection_fee: money_from_value(v.get("connectionFee")),
        minimum_charge: money_from_value(v.get("minimumCharge")),
        first_increment_seconds: v.get("firstIncrementSeconds").as_i64(),
        next_increment_seconds: v.get("nextIncrementSeconds").as_i64(),
    }
}

pub fn rated_call_to_value(c: &RatedCall) -> Value {
    Value::obj(vec![
        ("prefix", Value::str(&c.prefix)),
        ("destination", Value::str(&c.destination)),
        ("billedSeconds", Value::Int(c.billed_seconds)),
        ("usageCharge", money_to_value(&c.usage_charge)),
        ("connectionFee", money_to_value(&c.connection_fee)),
        ("total", money_to_value(&c.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let rates: Vec<CallRate> = args[2].as_arr().iter().map(call_rate_from_value).collect();
    rated_call_to_value(&rate_call(args[0].as_str(), args[1].as_i64(), &rates, args[3].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 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.call-rating
Download for Rust telecoms.call-rating-1.0.1-rust.fune · 46,455 bytes sha256 3a1a8e6ef14c949991fa18a895294f55fb6be5048e69ad60558b87227b67c885

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

The whole function, every language, is one file too: telecoms.call-rating-1.0.1.fune, 53,002 bytes, sha256 8ce31e9dc6f4de068db64d17e0eda321b01d477f7d60fd5f6aa692a6c936a440. 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.call-rating

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

// fune: after telecoms.call-rating

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 telecoms.call-rating
// fune: replace money.amount in telecoms.call-rating

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.call-rating --steps.

// fune: step telecoms.call-rating 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 UK landline on 60/60: 125 seconds is billed as 3 minutes +441632960001, 125, rates ×5, half-up → prefix 44, destination United Kingdom fixed, billed seconds 180, usage charge £0.30, connection fee £0.00, total £0.30
exactly one minute on 60/60 is one minute 441632960001, 60, rates ×5, half-up → prefix 44, destination United Kingdom fixed, billed seconds 60, usage charge £0.10, connection fee £0.00, total £0.10
61 seconds on 60/60 is billed as two minutes 441632960001, 61, rates ×5, half-up → prefix 44, destination United Kingdom fixed, billed seconds 120, usage charge £0.20, connection fee £0.00, total £0.20
a UK mobile matches 447, the longest prefix, not 44; 125 s on 30/6 is 126 s +447700900123, 125, rates ×5, half-up → prefix 447, destination United Kingdom mobile, billed seconds 126, usage charge £0.53, connection fee £0.05, total £0.58
the same call rounded down 447700900123, 125, rates ×5, down → prefix 447, destination United Kingdom mobile, billed seconds 126, usage charge £0.52, connection fee £0.05, total £0.57
a call inside the first increment is billed the whole first increment 447700900123, 30, rates ×5, half-up → prefix 447, destination United Kingdom mobile, billed seconds 30, usage charge £0.13, connection fee £0.05, total £0.18
one second past the first increment adds one 6-second block 447700900123, 31, rates ×5, half-up → prefix 447, destination United Kingdom mobile, billed seconds 36, usage charge £0.15, connection fee £0.05, total £0.20
per-second billing on 1/1 12025550123, 61, rates ×5, half-up → prefix 1, destination USA and Canada, billed seconds 61, usage charge £0.05, connection fee £0.00, total £0.05
the same per-second call rounded up 12025550123, 61, rates ×5, up → prefix 1, destination USA and Canada, billed seconds 61, usage charge £0.06, connection fee £0.00, total £0.06
a short call to France is raised to the minimum charge 33142685300, 10, rates ×5, half-up → prefix 33, destination France fixed, billed seconds 30, usage charge £0.06, connection fee £0.00, total £0.20
Show the other 12 tests
CaseArgumentsExpected
a French mobile with a connection fee 33612345678, 59, rates ×5, half-up → prefix 336, destination France mobile, billed seconds 60, usage charge £0.30, connection fee £0.10, total £0.40
an unanswered call costs nothing, minimum charge included 33142685300, 0, rates ×5, half-up → prefix 33, destination France fixed, billed seconds 0, usage charge £0.00, connection fee £0.00, total £0.00
a number no prefix matches is an error 81312345678, 60, rates ×5, half-up → error: no rate for dialled number 81312345678
a number with punctuation is an error 44-20-7946-0000, 60, rates ×5, half-up → error: dialled number must be international format digits
a negative duration is an error 441632960001, -1, rates ×5, half-up → error: duration must be whole seconds of 0 or more
a duplicate prefix in the deck is an error 441632960001, 60, rates ×6, half-up → error: duplicate prefix 44
a zero billing increment is an error 441632960001, 60, rates ×1, half-up → error: billing increments must be 1 second or more
a deck in two currencies is an error 441632960001, 60, rates ×2, half-up → error: currency mismatch
an unknown rounding mode is an error 441632960001, 60, rates ×5, nearest → error: unknown rounding mode
a trailing newline is not part of a dialled number 442079460000 , 60, rates ×5, half-up → error: dialled number must be international format digits
a trailing newline after a leading + is not part of a dialled number +442079460000 , 60, rates ×5, half-up → error: dialled number must be international format digits
a trailing newline is not part of a rate prefix 441632960001, 60, rates ×1, half-up → error: rate prefix must be digits

More from the author

An unanswered call (0 seconds) is not charged at all: no connection fee, no minimum. Prices are per minute in minor units, so a rate of 0.5p a minute cannot be written; use a deck in a smaller currency unit if you need one.

The rate deck is an argument because every operator has its own and changes it often; this capability holds no rates of its own. Deck rows are checked on every call: prefixes must be digits and unique, increments at least 1 second, and every price in one currency.

Errors: no matching prefix, a dialled number that is not digits, a negative duration, a duplicate or non-numeric prefix, an increment below 1, mixed currencies, and an unknown rounding mode.

1.0.1 fixes Python accepting a trailing newline in dialledNumber and a rate's prefix; adds tests.

Files

PathBytes
README.md2,035
impl/python.py3,341
impl/rust.rs4,591
impl/typescript.ts3,001
vectors.json31,244