Normalising subscription intervals in code

Updated 2026-08-04

Every revenue platform expresses recurring prices differently, and every MRR calculation reduces to the same operation: how many months is one billing cycle?

Once you have that, the rest is division.

The function

export type Interval = "day" | "week" | "month" | "year";

const MONTHS_PER = {
  day: 12 / 365,
  week: 12 / 52,
  month: 1,
  year: 12,
} as const;

/**
 * Monthly contribution of a recurring charge.
 * monthly = amount / (months_per_cycle × intervalCount)
 */
export function toMonthly(
  amount: number,
  interval: Interval,
  intervalCount = 1,
): number {
  const cycles = MONTHS_PER[interval] * Math.max(1, intervalCount);
  return cycles > 0 ? amount / cycles : 0;
}

That's the whole thing. It's the function FRGMNT uses across every platform.

Worked examples

toMonthly(29,  "month")      // 29        — monthly plan
toMonthly(490, "year")       // 40.83     — annual ÷ 12
toMonthly(4.99,"week")       // 21.62     — × 52/12
toMonthly(150, "month", 3)   // 50        — quarterly
toMonthly(800, "year", 2)    // 33.33     — two-year plan
toMonthly(1,   "day")        // 30.42     — daily

Why the constants are what they are

week: 12 / 52 — 0.2308 months per week. Dividing by that multiplies by 4.333. The tempting 0.25 (four weeks a month) undercounts weekly revenue by about 7.7%, consistently. See weekly subscriptions and MRR.

day: 12 / 365 — 0.0329 months per day, so ×30.42. Using 30 is off by 1.4%.

intervalCount — platforms express "every 3 months" as interval: "month", interval_count: 3, not as a quarterly interval type. Ignore the count and you'll book a quarterly plan at three times its true monthly value. This is the single most common bug in hand-rolled versions.

The design properties that matter

Pure. No dates, no I/O, no clock. Same inputs, same output, always — which means it's trivially testable and can run identically on a phone, in a CLI, or in a worker.

Guarded. Math.max(1, intervalCount) stops a zero or missing count producing a division by zero. cycles > 0 is belt and braces.

Platform-agnostic. It knows nothing about Stripe. Stripe, Lemon Squeezy and Paddle all describe intervals with an enum plus a count, so one function serves all three — which is exactly why FRGMNT's revenue adapters are framework-free TypeScript shared by the app, the CLI and the MCP server.

Testing it

The cases worth asserting:

expect(toMonthly(490, "year")).toBeCloseTo(40.833, 3);
expect(toMonthly(4.99, "week")).toBeCloseTo(21.623, 3);
expect(toMonthly(150, "month", 3)).toBe(50);
expect(toMonthly(29, "month", 0)).toBe(29);   // guard against 0

Floating point means comparing to three decimal places rather than exact equality. If you need exact currency arithmetic, work in minor units (integer cents) and only convert for display.

Frequently asked

How do you convert a billing interval to a monthly amount in code?

Divide the amount by the number of months in one billing cycle. A year is 12 months, a week is 12/52 of a month, a day is 12/365. Multiply by the interval count for intervals like every 3 months.

Why use 12/52 rather than 0.25 for weekly?

Because a month is not exactly four weeks. 12/52 gives 0.2308 months per week, so a weekly amount multiplies by about 4.333 — using 4 undercounts by roughly 8 percent.

Read next