subscriptions.mrr-arr
Monthly and annual recurring revenue from a list of subscriptions, normalising intervals and leaving out trials.
1.1.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
MRR (monthly recurring revenue) and ARR (annual recurring revenue) from the subscriptions a business has on one day. Every subscription is first turned into what it earns in a year, exactly:
| interval | a year is | |----------|----------------| | year | 1 interval | | quarter | 4 intervals | | month | 12 intervals | | week | 52 intervals | | day | 365 intervals |
For example
mrr_arr(subscriptions ×1, GBP)→ mrr £49.00, arr £588.00, counted 1, excluded 0 one monthly plan at 49.00mrr_arr(subscriptions ×1, GBP)→ mrr £82.50, arr £990.00, counted 1, excluded 0 one annual plan at 990.00 is 82.50 a monthmrr_arr(subscriptions ×1, GBP)→ mrr £8.33, arr £100.00, counted 1, excluded 0 an annual plan of 100.00 is 8.33 a month
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 mrr_arr(subscriptions: &[RecurringCharge], currency: &str) -> MrrArr
| subscriptions | RecurringCharge[] | one entry per subscription, all in currency |
| currency | string | the currency of the result, required so an empty list still has one |
| returns | MrrArr |
The types it declares, generated into your project
// SubscriptionStatus is a string in Rust, one of: "active", "past-due", "trialing", "paused", "canceled".
// Parameters take it as &str and results hold it as String.
/// One subscription's recurring charge.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecurringCharge {
/// charged every intervalCount intervals, after recurring discounts, before tax
pub amount: Money,
pub interval: String,
/// 1 or more: month with 6 is every six months
pub interval_count: i64,
/// only active and past-due count towards MRR
pub status: String,
}
/// Recurring revenue, each figure rounded once from the exact total.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrrArr {
pub mrr: Money,
pub arr: Money,
/// subscriptions included: active and past-due
pub counted: i64,
/// subscriptions left out: trialing, paused and canceled
pub excluded: i64,
}
Your code names it in one line, in the file that uses it
fune!(subscriptions.mrr-arr@^1); // then call mrr_arr(…)
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_rational::{add_rational, multiply_rational, rational, rational_to_integer}; ← from math.rational ^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
/// MRR and ARR from active and past-due subscriptions. Every charge becomes an
/// exact annual amount first, and each total is rounded once at the end, so a
/// list of annual plans does not lose a penny per plan.
///
/// # Panics
/// Panics on a currency mismatch, a negative amount, an interval count below
/// 1, or an unknown interval or status.
pub fn mrr_arr(subscriptions: &[RecurringCharge], currency: &str) -> MrrArr {
let zero = money(0, currency);
let mut annual = rational(0, 1);
let mut counted = 0;
let mut excluded = 0;
for s in subscriptions {
assert_same_currency(&zero, &s.amount);
if s.amount.minor < 0 {
panic!("recurring amount must not be negative, received {}", s.amount.minor);
}
if s.interval_count < 1 {
panic!("interval count must be 1 or more, received {}", s.interval_count);
}
let per_year: i64 = match s.interval.as_str() {
"day" => 365,
"week" => 52,
"month" => 12,
"quarter" => 4,
"year" => 1,
other => panic!(
"unknown billing interval \"{}\": expected day, week, month, quarter or year",
other
),
};
match s.status.as_str() {
"active" | "past-due" => {
counted += 1;
annual = add_rational(&annual, &rational(s.amount.minor * per_year, s.interval_count));
}
"trialing" | "paused" | "canceled" => excluded += 1,
other => panic!(
"unknown subscription status \"{}\": expected active, past-due, trialing, paused or canceled",
other
),
}
}
MrrArr {
mrr: money(rational_to_integer(&multiply_rational(&annual, &rational(1, 12)), "half-up"), currency),
arr: money(rational_to_integer(&annual, "half-up"), currency),
counted,
excluded,
}
}
pub fn recurring_charge_from_value(v: &Value) -> RecurringCharge {
RecurringCharge {
amount: money_from_value(v.get("amount")),
interval: v.get("interval").as_str().to_string(),
interval_count: v.get("intervalCount").as_i64(),
status: v.get("status").as_str().to_string(),
}
}
pub fn mrr_arr_to_value(r: &MrrArr) -> Value {
Value::obj(vec![
("mrr", money_to_value(&r.mrr)),
("arr", money_to_value(&r.arr)),
("counted", Value::Int(r.counted)),
("excluded", Value::Int(r.excluded)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let subs: Vec<RecurringCharge> = args[0].as_arr().iter().map(recurring_charge_from_value).collect();
mrr_arr_to_value(&mrr_arr(&subs, args[1].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 3 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.mrr-arr
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./subscriptions.mrr-arr-1.1.0-rust.fune, or fetch it from a terminal with fune pull subscriptions.mrr-arr@1.1.0:rust.
The whole function, every language, is one file too: subscriptions.mrr-arr-1.1.0.fune, 20,656 bytes, sha256 00720786d3b507dce9b22f2e8966b5ec051a2a993e0df8eef8ed7f42d5596938. 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.mrr-arr
after — your function gets the result and the arguments, and returns the final result.
// fune: after subscriptions.mrr-arr
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.rational in subscriptions.mrr-arr
// fune: replace money.amount in subscriptions.mrr-arr
// fune: replace subscriptions.next-billing-date in subscriptions.mrr-arr
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.mrr-arr --steps.
// fune: step subscriptions.mrr-arr 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 | |
|---|---|---|---|
| one monthly plan at 49.00 | subscriptions ×1, GBP | → | mrr £49.00, arr £588.00, counted 1, excluded 0 |
| one annual plan at 990.00 is 82.50 a month | subscriptions ×1, GBP | → | mrr £82.50, arr £990.00, counted 1, excluded 0 |
| an annual plan of 100.00 is 8.33 a month | subscriptions ×1, GBP | → | mrr £8.33, arr £100.00, counted 1, excluded 0 |
| three annual plans of 100.00 are 25.00 of MRR, not 3 x 8.33 = 24.99 | subscriptions ×3, GBP | → | mrr £25.00, arr £300.00, counted 3, excluded 0 |
| monthly, annual and quarterly plans together | subscriptions ×3, GBP | → | mrr £67.33, arr £808.00, counted 3, excluded 0 |
| a trial is left out of MRR and counted as excluded | subscriptions ×2, GBP | → | mrr £49.00, arr £588.00, counted 1, excluded 1 |
| past-due still counts; paused and canceled do not | subscriptions ×3, GBP | → | mrr £49.00, arr £588.00, counted 1, excluded 2 |
| a weekly plan uses 52 weeks to the year | subscriptions ×1, GBP | → | mrr £43.33, arr £520.00, counted 1, excluded 0 |
| a daily plan uses 365 days to the year | subscriptions ×1, GBP | → | mrr £30.42, arr £365.00, counted 1, excluded 0 |
| every six months: the price is earned twice a year | subscriptions ×1, GBP | → | mrr £50.00, arr £600.00, counted 1, excluded 0 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| every two years: half the price each year | subscriptions ×1, GBP | → | mrr £8.33, arr £100.00, counted 1, excluded 0 |
| half a minor unit of MRR rounds up | subscriptions ×1, GBP | → | mrr £0.01, arr £0.06, counted 1, excluded 0 |
| no subscriptions is zero in the requested currency | , EUR | → | mrr €0.00, arr €0.00, counted 0, excluded 0 |
| only trials: nothing counted | subscriptions ×1, GBP | → | mrr £0.00, arr £0.00, counted 0, excluded 1 |
| yen | subscriptions ×2, JPY | → | mrr ¥1,980, arr ¥23,760, counted 2, excluded 0 |
| a subscription in another currency is an error | subscriptions ×1, GBP | → | error: currency mismatch |
| a negative amount is an error | subscriptions ×1, GBP | → | error: recurring amount must not be negative |
| an interval count of zero is an error | subscriptions ×1, GBP | → | error: interval count must be 1 or more |
| an unknown status is an error | subscriptions ×1, GBP | → | error: unknown subscription status |
More from the author
divided by `intervalCount` (a 6-monthly plan earns its price twice a year). ARR is the sum of those, and MRR is the same sum divided by 12. Both are rounded once, half-up to the minor unit, from the exact fraction (math.rational). Rounding each subscription's monthly share first and adding up is the naive way and it drifts: three annual plans of 100.00 are 25.00 of MRR, but 8.33 + 8.33 + 8.33 is 24.99. Because each figure is rounded separately, MRR x 12 can differ from ARR by a few minor units; both are right.
Weeks and days are conventions, not calendar facts: 52 weeks and 365 days to the year. Businesses that use 52.14 weeks or 365.25 days get slightly different figures for weekly and daily plans; monthly, quarterly and annual plans are exact under any convention.
What counts. `active` and `past-due` subscriptions count: a failed payment still in dunning has not churned yet, so it stays in MRR until it is cancelled. (Metrics tools differ on this; filter past-due out before calling if yours does.) `trialing` subscriptions are left out (no revenue yet), as are `paused` and `canceled` ones. The amount should be the recurring price after recurring discounts and before tax; one-off charges, usage and setup fees are not recurring revenue and do not belong here.
Errors: an amount in a currency other than `currency`, a negative amount, and an interval count below 1.
Since 1.1.0 the `interval` of a `RecurringCharge` is the `BillingInterval` declared by `subscriptions.next-billing-date` (the same five values, `day`, `week`, `month`, `quarter` and `year`), rather than a copy declared here, so a subscription record typed for one capability is accepted by the other. `BillingInterval` can still be imported from this capability's module in TypeScript and Python, which re-export it. The answers are the same as 1.0.0's.
Files
| Path | Bytes |
|---|---|
| README.md | 2,269 |
| impl/python.py | 2,299 |
| impl/rust.rs | 2,957 |
| impl/typescript.ts | 2,317 |
| vectors.json | 6,335 |