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.
def mrr_arr(subscriptions: Sequence[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 = Literal["active", "past-due", "trialing", "paused", "canceled"]
@dataclass(frozen=True)
class RecurringCharge:
"""One subscription's recurring charge."""
#: charged every intervalCount intervals, after recurring discounts, before tax
amount: Money
interval: BillingInterval
#: 1 or more: month with 6 is every six months
interval_count: int
#: only active and past-due count towards MRR
status: SubscriptionStatus
@dataclass(frozen=True)
class MrrArr:
"""Recurring revenue, each figure rounded once from the exact total."""
mrr: Money
arr: Money
#: subscriptions included: active and past-due
counted: int
#: subscriptions left out: trialing, paused and canceled
excluded: int
Your code names it in one line, in the file that uses it
from fune.subscriptions.mrr_arr import mrr_arr # 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.
from typing import Dict, Sequence
from .math_rational import add_rational, multiply_rational, rational, rational_to_integer ← from math.rational ^1.0.0 · built alongside by fune
from .money_amount import assert_same_currency, money ← from money.amount ^1.0.0 · built alongside by fune
from .subscriptions_mrr_arr_types import MrrArr, RecurringCharge
# 1.0.0 declared its own BillingInterval, which callers imported from this
# module; it is now subscriptions.next-billing-date's, imported here so they
# still can.
from .subscriptions_next_billing_date import BillingInterval ← from subscriptions.next-billing-date ^1.0.0 · built alongside by fune
PER_YEAR: Dict[BillingInterval, int] = {"day": 365, "week": 52, "month": 12, "quarter": 4, "year": 1}
def mrr_arr(subscriptions: Sequence[RecurringCharge], currency: str) -> MrrArr:
"""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.
"""
zero = money(0, currency)
annual = rational(0, 1)
counted = 0
excluded = 0
for s in subscriptions:
assert_same_currency(zero, s.amount)
if s.amount.minor < 0:
raise ValueError("recurring amount must not be negative, received %d" % (s.amount.minor,))
if isinstance(s.interval_count, bool) or not isinstance(s.interval_count, int) or s.interval_count < 1:
raise ValueError("interval count must be 1 or more, received %r" % (s.interval_count,))
if s.interval not in PER_YEAR:
raise ValueError(
'unknown billing interval "%s": expected day, week, month, quarter or year' % (s.interval,)
)
if s.status in ("active", "past-due"):
counted += 1
annual = add_rational(annual, rational(s.amount.minor * PER_YEAR[s.interval], s.interval_count))
elif s.status in ("trialing", "paused", "canceled"):
excluded += 1
else:
raise ValueError(
'unknown subscription status "%s": expected active, past-due, trialing, paused or canceled'
% (s.status,)
)
return 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=counted,
excluded=excluded,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./subscriptions.mrr-arr-1.1.0-python.fune, or fetch it from a terminal with fune pull subscriptions.mrr-arr@1.1.0:python.
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 |