Functional Weave
Code in TypeScript

subscriptions.churn

Logo churn, gross and net revenue churn, and gross and net revenue retention for one period, in basis points.

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

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

What it does

The standard SaaS churn and retention rates for one period (usually a month), all measured against the customers and MRR that existed at the start of it:

logo churn = customersLost / customersAtStart gross revenue churn = (contraction + churned) / mrrAtStart net revenue churn = (contraction + churned - expansion) / mrrAtStart gross revenue retention = (mrrAtStart - contraction - churned) / mrrAtStart net revenue retention = (mrrAtStart + expansion - contraction - churned) / mrrAtStart

For example

  • churnMetrics(200, 7, £10,000.00, £500.00, £200.00, £300.00) → logo churn basis points 3.5%, gross revenue churn basis points 5%, net revenue churn basis points 0%, gross revenue retention basis points 95%, net revenue retention basis points … a typical month: expansion exactly offsets losses, so net churn is zero and NRR 100%
  • churnMetrics(50, 5, £100.00, £10.00, £10.00, £10.00) → logo churn basis points 10%, gross revenue churn basis points 20%, net revenue churn basis points 10%, gross revenue retention basis points 80%, net revenue retention basis points… ChartMogul's example: 100 + 10 - 10 - 10 is 90% net retention
  • churnMetrics(40, 1, £1,000.00, £150.00, £20.00, £30.00) → logo churn basis points 2.5%, gross revenue churn basis points 5%, net revenue churn basis points -10%, gross revenue retention basis points 95%, net revenue retention basis point… negative churn: expansion beats losses, NRR above 100%

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 churnMetrics(customersAtStart: number, customersLost: number, mrrAtStart: Money, expansionMrr: Money, contractionMrr: Money, churnedMrr: Money): ChurnMetrics
customersAtStartintpaying customers at the start of the period
customersLostintof those, how many had cancelled by its end; customers who joined during the period are not counted
mrrAtStartMoneyMRR from the customers at the start of the period
expansionMrrMoneyMRR added by those customers during the period (upgrades, seats, reactivations if you count them)
contractionMrrMoneyMRR lost to downgrades by customers who stayed
churnedMrrMoneyMRR lost with the customers who cancelled
returnsChurnMetrics

The type it declares, generated into your project

/** Rates in basis points (10000 is 100%), rounded half-up; null when the period started with nothing to measure against. */
export interface ChurnMetrics {
  /** customersLost / customersAtStart */
  readonly logoChurnBasisPoints: number | null;
  /** (contraction + churned) / mrrAtStart */
  readonly grossRevenueChurnBasisPoints: number | null;
  /** (contraction + churned - expansion) / mrrAtStart; negative when expansion wins */
  readonly netRevenueChurnBasisPoints: number | null;
  /** (mrrAtStart - contraction - churned) / mrrAtStart */
  readonly grossRevenueRetentionBasisPoints: number | null;
  /** (mrrAtStart + expansion - contraction - churned) / mrrAtStart */
  readonly netRevenueRetentionBasisPoints: number | null;
}

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

import { churnMetrics } from "#fune/subscriptions.churn@^1";
impl/typescript.ts · 44 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 { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type ChurnMetrics } from "./subscriptions_churn_types.ts";

function rate(numerator: number, denominator: number): number | null {
  return denominator === 0 ? null : roundDiv(numerator * 10000, denominator, "half-up");
}

/**
 * Logo churn, gross and net revenue churn, and gross and net revenue
 * retention for one period, each as basis points of the starting cohort.
 */
export function churnMetrics(
  customersAtStart: number,
  customersLost: number,
  mrrAtStart: Money,
  expansionMrr: Money,
  contractionMrr: Money,
  churnedMrr: Money,
): ChurnMetrics {
  if (!Number.isInteger(customersAtStart) || customersAtStart < 0 || !Number.isInteger(customersLost) || customersLost < 0) {
    throw new RangeError(`customer counts must be whole numbers of 0 or more, received ${customersAtStart} and ${customersLost}`);
  }
  if (customersLost > customersAtStart) {
    throw new RangeError(`customers lost must not exceed customers at start, received ${customersLost} of ${customersAtStart}`);
  }
  for (const amount of [expansionMrr, contractionMrr, churnedMrr]) assertSameCurrency(mrrAtStart, amount);
  const start = mrrAtStart.minor;
  const expansion = expansionMrr.minor;
  const lost = contractionMrr.minor + churnedMrr.minor;
  if (start < 0 || expansion < 0 || contractionMrr.minor < 0 || churnedMrr.minor < 0) {
    throw new RangeError("MRR amounts must not be negative");
  }
  if (lost > start) {
    throw new RangeError(`contraction and churned MRR must not exceed MRR at start, received ${lost} of ${start}`);
  }
  return {
    logoChurnBasisPoints: rate(customersLost, customersAtStart),
    grossRevenueChurnBasisPoints: rate(lost, start),
    netRevenueChurnBasisPoints: rate(lost - expansion, start),
    grossRevenueRetentionBasisPoints: rate(start - lost, start),
    netRevenueRetentionBasisPoints: rate(start + expansion - lost, start),
  };
}

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 subscriptions.churn
Download for TypeScript subscriptions.churn-1.0.0-typescript.fune · 13,667 bytes sha256 080be69486b9e208e1861328f7f008cbbaece013794ba9d440d1e6b42a7262d9

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./subscriptions.churn-1.0.0-typescript.fune, or fetch it from a terminal with fune pull subscriptions.churn@1.0.0:typescript.

The whole function, every language, is one file too: subscriptions.churn-1.0.0.fune, 19,176 bytes, sha256 2de1c509adb029c3c9ebed57f1d3285cc2bbcaa850afc64282e4384e7ceb12a6. 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 subscriptions.churn

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

// fune: after subscriptions.churn

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 subscriptions.churn
// fune: replace money.amount in subscriptions.churn

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 subscriptions.churn --steps.

// fune: step subscriptions.churn 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 typical month: expansion exactly offsets losses, so net churn is zero and NRR 100% 200, 7, £10,000.00, £500.00, £200.00, £300.00 → logo churn basis points 3.5%, gross revenue churn basis points 5%, net revenue churn basis points 0%, gross revenue retention basis points 95%, net revenue retention basis points …
ChartMogul's example: 100 + 10 - 10 - 10 is 90% net retention 50, 5, £100.00, £10.00, £10.00, £10.00 → logo churn basis points 10%, gross revenue churn basis points 20%, net revenue churn basis points 10%, gross revenue retention basis points 80%, net revenue retention basis points…
negative churn: expansion beats losses, NRR above 100% 40, 1, £1,000.00, £150.00, £20.00, £30.00 → logo churn basis points 2.5%, gross revenue churn basis points 5%, net revenue churn basis points -10%, gross revenue retention basis points 95%, net revenue retention basis point…
one lost out of three is 33.33%, rounded to a whole basis point 3, 1, £300.00, £0.00, £0.00, £100.00 → logo churn basis points 33.33%, gross revenue churn basis points 33.33%, net revenue churn basis points 33.33%, gross revenue retention basis points 66.67%, net revenue retention …
two lost out of three rounds up to 66.67% 3, 2, £300.00, £0.00, £0.00, £200.00 → logo churn basis points 66.67%, gross revenue churn basis points 66.67%, net revenue churn basis points 66.67%, gross revenue retention basis points 33.33%, net revenue retention …
half a basis point of net churn rounds away from zero 10, 0, £200.00, £0.01, £0.00, £0.00 → logo churn basis points 0%, gross revenue churn basis points 0%, net revenue churn basis points -0.01%, gross revenue retention basis points 100%, net revenue retention basis poin…
everyone lost: 100% churn and nothing retained 10, 10, £50.00, £0.00, £0.00, £50.00 → logo churn basis points 100%, gross revenue churn basis points 100%, net revenue churn basis points 100%, gross revenue retention basis points 0%, net revenue retention basis poin…
no customers and no MRR at the start: every rate is null, not zero 0, 0, £0.00, £0.00, £0.00, £0.00 → logo churn basis points —, gross revenue churn basis points —, net revenue churn basis points —, gross revenue retention basis points —, net revenue retention basis points —
free customers only: logo churn exists, revenue rates do not 5, 1, £0.00, £0.00, £0.00, £0.00 → logo churn basis points 20%, gross revenue churn basis points —, net revenue churn basis points —, gross revenue retention basis points —, net revenue retention basis points —
a quiet month: nobody left, nothing changed 120, 0, $6,000.00, $0.00, $0.00, $0.00 → logo churn basis points 0%, gross revenue churn basis points 0%, net revenue churn basis points 0%, gross revenue retention basis points 100%, net revenue retention basis points 1…
Show the other 5 tests
CaseArgumentsExpected
more customers lost than there were is an error 5, 6, £10.00, £0.00, £0.00, £0.00 → error: customers lost must not exceed customers at start
a negative customer count is an error -1, 0, £10.00, £0.00, £0.00, £0.00 → error: customer counts must be whole numbers of 0 or more
losing more MRR than there was is an error 5, 1, £10.00, £0.00, £6.00, £5.00 → error: contraction and churned MRR must not exceed MRR at start
a negative expansion is an error 5, 1, £10.00, -£0.10, £0.00, £0.00 → error: MRR amounts must not be negative
amounts in different currencies are an error 5, 1, £10.00, €0.10, £0.00, £0.00 → error: currency mismatch

More from the author

These are ChartMogul's published definitions (Gross MRR Churn Rate, Net MRR Churn Rate and Net Revenue Retention), which are the ones most SaaS reporting uses. Net revenue churn goes negative when expansion from existing customers outweighs what they took away ("negative churn"), and net revenue retention goes above 10000 (100%) in the same case. ChartMogul adds reactivation MRR to expansion in NRR and net churn; if you want the same, pass expansion plus reactivation as `expansionMrr`.

Every input is about the cohort at the start of the period. New customers who joined during it, and the MRR they brought, are left out: they are growth, not retention, and including them is the most common way churn gets under-reported. A customer who joined and cancelled within the period is not in `customersLost` either.

Each rate is one exact division rounded half-up (away from zero for a negative net churn) to a whole basis point, so 1 lost out of 3 is 3333 (33.33%). A rate is null rather than an error when its denominator is zero: a business with no customers at the start of the month has no churn rate, not a churn rate of zero.

Errors: negative counts or amounts, more customers lost than there were, more MRR contracted and churned than there was, and amounts in different currencies.

Sources: ChartMogul, "Revenue churn (net and gross revenue churn rate)", https://chartmogul.com/saas-metrics/revenue-churn/ and "Net Revenue Retention (NRR)", https://chartmogul.com/saas-metrics/nrr/.

Files

PathBytes
README.md2,052
impl/python.py2,210
impl/rust.rs3,098
impl/typescript.ts1,999
vectors.json5,604