Functional Weave
Code in TypeScript

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.00
  • mrrArr(subscriptions ×1, GBP) → mrr £82.50, arr £990.00, counted 1, excluded 0 one annual plan at 990.00 is 82.50 a month
  • mrrArr(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
subscriptionsRecurringCharge[]one entry per subscription, all in currency
currencystringthe currency of the result, required so an empty list still has one
returnsMrrArr

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";
impl/typescript.ts · 50 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 { 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
Download for TypeScript subscriptions.mrr-arr-1.1.0-typescript.fune · 15,158 bytes sha256 730aebae79246b8ec6a81f9f4e7cca5424b268d9f073ef77404276ca921087b7

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,269
impl/python.py2,299
impl/rust.rs2,957
impl/typescript.ts2,317
vectors.json6,335