Functional Weave
Code in Rust

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

  • churn_metrics(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%
  • churn_metrics(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
  • churn_metrics(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.

pub fn churn_metrics(customers_at_start: i64, customers_lost: i64, mrr_at_start: &Money, expansion_mrr: &Money, contraction_mrr: &Money, churned_mrr: &Money) -> ChurnMetrics
customers_at_startintpaying customers at the start of the period
customers_lostintof those, how many had cancelled by its end; customers who joined during the period are not counted
mrr_at_startMoneyMRR from the customers at the start of the period
expansion_mrrMoneyMRR added by those customers during the period (upgrades, seats, reactivations if you count them)
contraction_mrrMoneyMRR lost to downgrades by customers who stayed
churned_mrrMoneyMRR 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChurnMetrics {
    /// customersLost / customersAtStart
    pub logo_churn_basis_points: Option<i64>,
    /// (contraction + churned) / mrrAtStart
    pub gross_revenue_churn_basis_points: Option<i64>,
    /// (contraction + churned - expansion) / mrrAtStart; negative when expansion wins
    pub net_revenue_churn_basis_points: Option<i64>,
    /// (mrrAtStart - contraction - churned) / mrrAtStart
    pub gross_revenue_retention_basis_points: Option<i64>,
    /// (mrrAtStart + expansion - contraction - churned) / mrrAtStart
    pub net_revenue_retention_basis_points: Option<i64>,
}

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

fune!(subscriptions.churn@^1);  // then call churn_metrics(…)
impl/rust.rs · 89 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 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_from_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

fn rate(numerator: i64, denominator: i64) -> Option<i64> {
    if denominator == 0 {
        None
    } else {
        Some(round_div(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.
///
/// # Panics
/// Panics on negative counts or amounts, more lost than there was, or mixed
/// currencies.
pub fn churn_metrics(
    customers_at_start: i64,
    customers_lost: i64,
    mrr_at_start: &Money,
    expansion_mrr: &Money,
    contraction_mrr: &Money,
    churned_mrr: &Money,
) -> ChurnMetrics {
    if customers_at_start < 0 || customers_lost < 0 {
        panic!(
            "customer counts must be whole numbers of 0 or more, received {} and {}",
            customers_at_start, customers_lost
        );
    }
    if customers_lost > customers_at_start {
        panic!(
            "customers lost must not exceed customers at start, received {} of {}",
            customers_lost, customers_at_start
        );
    }
    for amount in [expansion_mrr, contraction_mrr, churned_mrr] {
        assert_same_currency(mrr_at_start, amount);
    }
    let start = mrr_at_start.minor;
    let expansion = expansion_mrr.minor;
    let lost = contraction_mrr.minor + churned_mrr.minor;
    if start < 0 || expansion < 0 || contraction_mrr.minor < 0 || churned_mrr.minor < 0 {
        panic!("MRR amounts must not be negative");
    }
    if lost > start {
        panic!(
            "contraction and churned MRR must not exceed MRR at start, received {} of {}",
            lost, start
        );
    }
    ChurnMetrics {
        logo_churn_basis_points: rate(customers_lost, customers_at_start),
        gross_revenue_churn_basis_points: rate(lost, start),
        net_revenue_churn_basis_points: rate(lost - expansion, start),
        gross_revenue_retention_basis_points: rate(start - lost, start),
        net_revenue_retention_basis_points: rate(start + expansion - lost, start),
    }
}

fn opt(v: Option<i64>) -> Value {
    match v {
        Some(n) => Value::Int(n),
        None => Value::Null,
    }
}

pub fn churn_metrics_to_value(m: &ChurnMetrics) -> Value {
    Value::obj(vec![
        ("logoChurnBasisPoints", opt(m.logo_churn_basis_points)),
        ("grossRevenueChurnBasisPoints", opt(m.gross_revenue_churn_basis_points)),
        ("netRevenueChurnBasisPoints", opt(m.net_revenue_churn_basis_points)),
        ("grossRevenueRetentionBasisPoints", opt(m.gross_revenue_retention_basis_points)),
        ("netRevenueRetentionBasisPoints", opt(m.net_revenue_retention_basis_points)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    churn_metrics_to_value(&churn_metrics(
        args[0].as_i64(),
        args[1].as_i64(),
        &money_from_value(&args[2]),
        &money_from_value(&args[3]),
        &money_from_value(&args[4]),
        &money_from_value(&args[5]),
    ))
}

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 subscriptions.churn
Download for Rust subscriptions.churn-1.0.0-rust.fune · 14,809 bytes sha256 7e4bdfb0b0823a4e58c780e72758dbd1d7d6411c8120c31791940f1679a36b98

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

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