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
mrrArr(subscriptions ×1, GBP)→ mrr £49.00, arr £588.00, counted 1, excluded 0 one monthly plan at 49.00mrrArr(subscriptions ×1, GBP)→ mrr £82.50, arr £990.00, counted 1, excluded 0 one annual plan at 990.00 is 82.50 a monthmrrArr(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.
export function mrrArr(subscriptions: readonly RecurringCharge[], currency: string): 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
export type SubscriptionStatus = "active" | "past-due" | "trialing" | "paused" | "canceled";
/** One subscription's recurring charge. */
export interface RecurringCharge {
/** charged every intervalCount intervals, after recurring discounts, before tax */
readonly amount: Money;
readonly interval: BillingInterval;
/** 1 or more: month with 6 is every six months */
readonly intervalCount: number;
/** only active and past-due count towards MRR */
readonly status: SubscriptionStatus;
}
/** Recurring revenue, each figure rounded once from the exact total. */
export interface MrrArr {
readonly mrr: Money;
readonly arr: Money;
/** subscriptions included: active and past-due */
readonly counted: number;
/** subscriptions left out: trialing, paused and canceled */
readonly excluded: number;
}
Your code names it in one line, in the file that uses it
import { mrrArr } from "#fune/subscriptions.mrr-arr@^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 Rational, addRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts"; ← from math.rational ^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 BillingInterval } from "./subscriptions_next_billing_date.ts"; ← from subscriptions.next-billing-date ^1.0.0 · built alongside by fune
import { type MrrArr, type RecurringCharge } from "./subscriptions_mrr_arr_types.ts";
// 1.0.0 declared its own BillingInterval, which callers imported from this
// module; it is now subscriptions.next-billing-date's, re-exported so they
// still can.
export type { BillingInterval };
const PER_YEAR = new Map<BillingInterval, number>([["day", 365], ["week", 52], ["month", 12], ["quarter", 4], ["year", 1]]);
/**
* 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.
*/
export function mrrArr(subscriptions: readonly RecurringCharge[], currency: string): MrrArr {
const zero = money(0, currency);
let annual: Rational = rational(0, 1);
let counted = 0;
let excluded = 0;
for (const s of subscriptions) {
assertSameCurrency(zero, s.amount);
if (s.amount.minor < 0) {
throw new RangeError(`recurring amount must not be negative, received ${s.amount.minor}`);
}
if (!Number.isInteger(s.intervalCount) || s.intervalCount < 1) {
throw new RangeError(`interval count must be 1 or more, received ${s.intervalCount}`);
}
const perYear = PER_YEAR.get(s.interval);
if (perYear === undefined) {
throw new RangeError(`unknown billing interval "${s.interval}": expected day, week, month, quarter or year`);
}
if (s.status === "active" || s.status === "past-due") {
counted += 1;
annual = addRational(annual, rational(s.amount.minor * perYear, s.intervalCount));
} else if (s.status === "trialing" || s.status === "paused" || s.status === "canceled") {
excluded += 1;
} else {
throw new RangeError(`unknown subscription status "${s.status}": expected active, past-due, trialing, paused or canceled`);
}
}
return {
mrr: money(rationalToInteger(multiplyRational(annual, rational(1, 12)), "half-up"), currency),
arr: money(rationalToInteger(annual, "half-up"), currency),
counted,
excluded,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 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.mrr-arr
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./subscriptions.mrr-arr-1.1.0-typescript.fune, or fetch it from a terminal with fune pull subscriptions.mrr-arr@1.1.0:typescript.
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 |