DEV Community

Justin Smith
Justin Smith

Posted on

Why average call duration misleads your VoIP billing (with a 10-line JavaScript function)

If you have ever sized a VoIP termination deal with a sentence like "our calls average 30 seconds", there is a good chance the invoice surprised you. The reason is simple: carriers round each call, not the average. This post shows the rounding as code, with two sets of calls that have the same average and very different bills.

How a billing rule works

Wholesale voice rates are quoted per minute, but the invoice counts seconds. The rule that turns a call's real duration into billed seconds is usually written as two numbers, minimum/step:

  • 60/60: every call bills at least 60 seconds, then in 60-second steps.
  • 6/6: at least 6 seconds, then 6-second steps.
  • 1/1: per second, no real minimum.
  • 60/1: the first minute bills in full, then per second.

The pair on one route tells you nothing about the pair on another, so you read it row by row in the rate deck. In words: a call no longer than the minimum bills the minimum, and a longer call bills the minimum plus the time beyond it, rounded up to the next whole step.

The function

function billedSeconds(duration, { min, step, fractions = "up" }) {
  let d = duration;
  if (fractions === "down") d = Math.floor(d);
  if (fractions === "nearest") d = Math.round(d);
  if (!(d > 0)) return 0;                          // no connected time, nothing to bill
  if (d <= min) return min;                        // short calls pay the minimum
  return min + Math.ceil((d - min) / step) * step; // then whole steps beyond it
}

function bill(durations, rule, ratePerMinute) {
  const seconds = durations.reduce((sum, d) => sum + billedSeconds(d, rule), 0);
  return { seconds, cost: (seconds / 60) * ratePerMinute };
}
Enter fullscreen mode Exit fullscreen mode

A 13-second call on 6/6 bills 6 + ceil(7 / 6) * 6 = 18 seconds. The same call on 60/60 bills 60.

Same average, different bill

Take two sets of ten calls. Set A is ten calls of 30 seconds. Set B is nine calls of 10 seconds and one call of 210 seconds. Both average exactly 30 seconds and both contain 300 seconds of talk time.

const A = Array(10).fill(30);
const B = [...Array(9).fill(10), 210];

const rules = {
  "60/60": { min: 60, step: 60 },
  "6/6":   { min: 6,  step: 6 },
  "1/1":   { min: 1,  step: 1 },
  "60/1":  { min: 60, step: 1 },
};

for (const [name, rule] of Object.entries(rules)) {
  console.log(name, bill(A, rule, 0.01), bill(B, rule, 0.01));
}
Enter fullscreen mode Exit fullscreen mode

Each line prints the billed seconds and cost for both sets. Collected into a table, at an illustrative rate of $0.01 per minute (the rate is made up; only the ratios matter):

Rule Set A seconds Set A cost Set B seconds Set B cost
60/60 600 $0.100 780 $0.130
6/6 300 $0.050 318 $0.053
1/1 300 $0.050 300 $0.050
60/1 600 $0.100 750 $0.125

On 60/60, the 300 seconds of real talk time costs you 600 billed seconds in Set A and 780 in Set B. Set B pays a full minute for each of its nine 10-second calls, then 240 seconds for the long one. On 1/1 the two sets cost the same, because there is nothing to round.

Now the trap. Suppose you estimate Set B's bill from its average:

const mean = B.reduce((a, b) => a + b) / B.length;       // 30
const estimate = B.length * billedSeconds(mean, rules["60/60"]); // 600
Enter fullscreen mode Exit fullscreen mode

The estimate is 600 seconds. The real answer is 780, so the estimate is 23% too low. The cause is that Math.ceil is not linear: the cost of the average call is not the average cost of the calls. The more your calls bunch up below the minimum, the larger the gap.

Edge cases worth deciding on purpose

Fractional seconds. If your call records have decimals, billing systems disagree. Some drop the fraction, some round to the nearest second, and some count any fraction as extra time. A 60.1-second call on 60/60:

const r = { min: 60, step: 60 };
billedSeconds(60.1, r);                          // 120: any fraction counts
billedSeconds(60.1, { ...r, fractions: "down" }); // 60:  fraction dropped
billedSeconds(60.5, { ...r, fractions: "nearest" }); // 120
Enter fullscreen mode Exit fullscreen mode

The same call bills 60 or 120 seconds depending on the rule, and at a boundary that is a 2x difference. Ask which rule applies, and get it in writing.

Sub-second calls. Some platforms round them up so they are never free. Twilio, for example, documented that completed sub-second calls are rounded up to 1 second from July 8, 2021 (changelog). In the function above, billedSeconds(0.4, { min: 1, step: 1 }) is 1, and with fractions: "down" it is 0. That is a policy choice, so check yours.

Zero-length calls. Attempts that never connect have no duration. The function returns 0 for them, so leave them out of the list you feed it.

A minimum that is not a multiple of the step. With a rule like 10/6, an 11-second call bills 16 seconds in the function above (the 10-second minimum plus one 6-second step). An implementation that rounds the whole duration up to the next step and then applies the minimum gets 12. Both are plausible, which is exactly why the rule on the rate deck matters more than any general example. This post, and the published worked examples I checked (such as 12/6 where a 14-second call bills 18 and 30/6 where a 34-second call bills 36), assume the "minimum plus steps" reading.

Try it on your own calls

Export the durations of your answered calls in seconds (from your CDRs), then:

import { readFileSync } from "node:fs";

const durations = readFileSync("durations.txt", "utf8")
  .split(/\s+/).map(Number).filter((d) => d > 0);

for (const [name, rule] of Object.entries(rules)) {
  const { seconds, cost } = bill(durations, rule, 0.01);
  console.log(name, seconds, cost.toFixed(2));
}
Enter fullscreen mode Exit fullscreen mode

Run each provider's rule against the same list and compare totals, not rates. A lower rate on a harsher rule can cost more on short calls, and the only way to see that is to price your real distribution.

A few tests to keep you honest

import assert from "node:assert/strict";

assert.equal(billedSeconds(13, { min: 6, step: 6 }), 18);
assert.equal(billedSeconds(13, { min: 60, step: 60 }), 60);
assert.equal(billedSeconds(61, { min: 60, step: 60 }), 120);
assert.equal(billedSeconds(14, { min: 12, step: 6 }), 18);
assert.equal(billedSeconds(34, { min: 30, step: 6 }), 36);
assert.equal(billedSeconds(0, { min: 60, step: 60 }), 0);
Enter fullscreen mode Exit fullscreen mode

If you would rather not run code

I put the same logic into a browser calculator: paste your call durations, set a minimum, step and rate for up to three routes, and it shows billed minutes, cost, and what an average-only estimate would have said. It runs in your browser and does not send your numbers anywhere: wholesale billing increment calculator.

The longer write-up, with the sources behind the rounding rules, is here: wholesale billing increments explained.

Disclosure: I work with the team at EaseDial, which publishes the calculator and guide linked above. The rates in this post are illustrative and do not describe any provider's pricing.

Top comments (0)