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
- Calculate MRR from the Stripe APIWorking code for computing true monthly recurring revenue from Stripe subscriptions, including interval normal…
- Weekly subscriptions and MRRMultiply by 4.333, not 4. Weekly plans are common in mobile apps and are almost always undercounted by about 8…
- The CLI and MCP serverQuery True MRR, today's cash and transactions from your shell — or let an AI assistant read your numbers.…