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
rateCall(+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 minutesrateCall(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 minuterateCall(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.
export function rateCall(dialledNumber: string, durationSeconds: number, rates: readonly CallRate[], mode: RoundingMode): RatedCall
| dialledNumber | string | international format digits, E.164 with or without the leading +: 447700900123 |
| durationSeconds | int | answered duration in whole seconds; 0 for an unanswered call |
| rates | CallRate[] | the rate deck; prefixes must be unique |
| mode | RoundingMode | how the per-minute charge rounds to a whole minor unit |
| returns | RatedCall |
The types it declares, generated into your project
/** One row of a rate deck. */
export interface CallRate {
/** international digits the number starts with, without + or 00: 44, 447 */
readonly prefix: string;
/** a name for invoices: United Kingdom mobile */
readonly destination: string;
/** price per 60 billed seconds */
readonly perMinute: Money;
/** added to every answered call; zero for none */
readonly connectionFee: Money;
/** the least an answered call costs, connection fee included; zero for none */
readonly minimumCharge: Money;
/** the first billed block: 60 in 60/60, 30 in 30/6, 1 in 1/1 */
readonly firstIncrementSeconds: number;
/** each later block: 60 in 60/60, 6 in 30/6, 1 in 1/1 */
readonly nextIncrementSeconds: number;
}
/** The rated call, itemised as a bill prints it. */
export interface RatedCall {
/** the matched prefix */
readonly prefix: string;
readonly destination: string;
/** duration rounded up to the billing increments */
readonly billedSeconds: number;
/** perMinute x billedSeconds / 60, rounded by mode */
readonly usageCharge: Money;
/** zero for an unanswered call */
readonly connectionFee: Money;
/** usage plus connection fee, raised to the minimum charge */
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { rateCall } from "#fune/telecoms.call-rating@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type RoundingMode, roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type CallRate, type RatedCall } from "./telecoms_call_rating_types.ts";
const DIGITS = /^[0-9]+$/;
/**
* 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.
*/
export function rateCall(dialledNumber: string, durationSeconds: number, rates: readonly CallRate[], mode: RoundingMode): RatedCall {
const number = dialledNumber.startsWith("+") ? dialledNumber.slice(1) : dialledNumber;
if (!DIGITS.test(number)) {
throw new RangeError(`dialled number must be international format digits, optionally with a leading +, received "${dialledNumber}"`);
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 0) {
throw new RangeError(`duration must be whole seconds of 0 or more, received ${durationSeconds}`);
}
const seen = new Set<string>();
let best: CallRate | null = null;
for (const rate of rates) {
if (!DIGITS.test(rate.prefix)) {
throw new RangeError(`rate prefix must be digits, received "${rate.prefix}"`);
}
if (seen.has(rate.prefix)) {
throw new RangeError(`duplicate prefix ${rate.prefix} in the rate deck`);
}
seen.add(rate.prefix);
if (!Number.isInteger(rate.firstIncrementSeconds) || rate.firstIncrementSeconds < 1 || !Number.isInteger(rate.nextIncrementSeconds) || rate.nextIncrementSeconds < 1) {
throw new RangeError(`billing increments must be 1 second or more, received ${rate.firstIncrementSeconds}/${rate.nextIncrementSeconds} for prefix ${rate.prefix}`);
}
assertSameCurrency(rates[0].perMinute, rate.perMinute);
assertSameCurrency(rates[0].perMinute, rate.connectionFee);
assertSameCurrency(rates[0].perMinute, rate.minimumCharge);
if (number.startsWith(rate.prefix) && (best === null || rate.prefix.length > best.prefix.length)) {
best = rate;
}
}
if (best === null) {
throw new RangeError(`no rate for dialled number ${number}`);
}
const currency = best.perMinute.currency;
if (durationSeconds === 0) {
const zero = money(0, currency);
return { prefix: best.prefix, destination: best.destination, billedSeconds: 0, usageCharge: zero, connectionFee: zero, total: zero };
}
const first = best.firstIncrementSeconds;
const next = best.nextIncrementSeconds;
const billedSeconds = durationSeconds <= first ? first : first + Math.ceil((durationSeconds - first) / next) * next;
const usage = roundDiv(best.perMinute.minor * billedSeconds, 60, mode);
const total = Math.max(usage + best.connectionFee.minor, best.minimumCharge.minor);
return {
prefix: best.prefix,
destination: best.destination,
billedSeconds,
usageCharge: money(usage, currency),
connectionFee: best.connectionFee,
total: money(total, currency),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 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.call-rating
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./telecoms.call-rating-1.0.1-typescript.fune, or fetch it from a terminal with fune pull telecoms.call-rating@1.0.1:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,035 |
| impl/python.py | 3,341 |
| impl/rust.rs | 4,591 |
| impl/typescript.ts | 3,001 |
| vectors.json | 31,244 |