osmoto.
Case StudiesBlogBook Consultation

Services

Stripe IntegrationSubscription BillingPayment Automation & AINext.js OptimizationAudit & Fix

Solutions

For FoundersFor SaaS CompaniesFor E-Commerce StoresFor Marketplaces

Resources

Implementation GuideWebhook Best PracticesPCI Compliance GuideStripe vs Alternatives
Case StudiesBlog
Book Consultation
osmoto.

Professional Stripe integration services

Services

  • Stripe Integration
  • Subscription Billing
  • E-Commerce Integration
  • Next.js Optimization
  • Audit & Fix

Solutions

  • For Founders
  • For SaaS
  • For E-Commerce
  • For Marketplaces
  • Integration as a Service

Resources

  • Implementation Guide
  • Webhook Guide
  • PCI Compliance
  • Stripe vs Alternatives

Company

  • About
  • Case Studies
  • Process
  • Pricing
  • Contact
© 2026 Osmoto · Professional Stripe Integration Services
Back to Blog
Subscription Billing20 min read

Calculating and Tracking MRR with Stripe Subscriptions

You've built a successful SaaS product on Stripe, customers are subscribing, and revenue is flowing in. Your board asks a simple question: "What's our Monthly R...

Osmoto Team

Senior Software Engineer

February 16, 2026
Calculating and Tracking MRR with Stripe Subscriptions

Understanding the MRR Calculation Challenge

You've built a successful SaaS product on Stripe, customers are subscribing, and revenue is flowing in. Your board asks a simple question: "What's our Monthly Recurring Revenue?" You pull up Stripe's dashboard, see various charts and numbers, but realize calculating accurate MRR isn't as straightforward as you thought. Subscriptions have different billing intervals, some customers are on trials, others have add-ons, and you're dealing with prorations, discounts, and currency conversions.

Monthly Recurring Revenue (MRR) is the lifeblood metric for subscription businesses, but Stripe doesn't calculate it directly. While Stripe provides excellent subscription management capabilities, the platform focuses on collecting payments rather than calculating normalized revenue metrics. This means you need to build your own MRR tracking system—and get it right, because inaccurate MRR calculations lead to poor business decisions, misleading investor reports, and failed revenue forecasts.

In this guide, we'll walk through the technical implementation of calculating and tracking MRR with Stripe subscriptions. You'll learn how to handle different subscription intervals, deal with edge cases like mid-month changes, implement proper webhook handling for real-time tracking, and build a reliable MRR reporting system that scales with your business.

What MRR Actually Means (And What It Doesn't)

Before diving into implementation, let's establish a precise definition. MRR is the normalized monthly value of all active subscriptions. The key word is "normalized"—you're converting all subscription revenue to a monthly equivalent, regardless of billing frequency.

Here's what counts toward MRR:

  • Monthly subscriptions at their full value
  • Annual subscriptions divided by 12
  • Quarterly subscriptions divided by 3
  • Add-ons and metered usage (when predictable)
  • Recurring discounts applied

What doesn't count:

  • One-time setup fees
  • Usage-based charges that vary significantly
  • Trial subscriptions (until they convert)
  • Canceled subscriptions (after cancellation takes effect)

The critical distinction: MRR represents committed recurring revenue, not cash collected. A customer who pays $1,200 annually contributes $100 to MRR each month, even though you received the full payment upfront. This normalization allows you to track growth trends independent of billing timing.

Retrieving Subscription Data from Stripe

The foundation of MRR calculation is accurate subscription data retrieval. Stripe's API provides everything you need, but you must understand which fields matter and how to interpret them correctly.

The Core Subscription Object

When you retrieve a subscription from Stripe, the critical fields for MRR calculation are:

interface StripeSubscriptionForMRR { id: string; status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid'; items: { data: Array<{ id: string; price: { id: string; unit_amount: number; // in cents currency: string; recurring: { interval: 'day' | 'week' | 'month' | 'year'; interval_count: number; }; }; quantity: number; }>; }; discount?: { coupon: { percent_off?: number; amount_off?: number; duration: 'forever' | 'once' | 'repeating'; duration_in_months?: number; }; }; trial_end?: number; current_period_start: number; current_period_end: number; }

Fetching Active Subscriptions

To calculate current MRR, you need all active subscriptions. Here's the proper approach:

import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', }); async function fetchActiveSubscriptions(): Promise<Stripe.Subscription[]> { const subscriptions: Stripe.Subscription[] = []; // Stripe paginates results - we need to fetch all pages for await (const subscription of stripe.subscriptions.list({ status: 'active', limit: 100, // maximum allowed expand: ['data.items.data.price', 'data.discount.coupon'], })) { subscriptions.push(subscription); } return subscriptions; }

Critical detail: Always use the expand parameter to include nested objects. Without it, you'll receive IDs instead of full objects, requiring additional API calls and significantly slowing down your calculation.

Handling Past Due and Trialing Subscriptions

The status: 'active' filter excludes some subscriptions that should contribute to MRR calculations:

async function fetchAllRevenueGeneratingSubscriptions() { const statuses: Stripe.Subscription.Status[] = [ 'active', 'trialing', // Include if trial will convert to paid 'past_due', // Still generating revenue until canceled ]; const allSubscriptions: Stripe.Subscription[] = []; for (const status of statuses) { for await (const subscription of stripe.subscriptions.list({ status, limit: 100, expand: ['data.items.data.price', 'data.discount.coupon'], })) { // Exclude trials that haven't started billing yet if (status === 'trialing' && subscription.trial_end) { const now = Math.floor(Date.now() / 1000); if (subscription.trial_end > now) { continue; // Skip active trials } } allSubscriptions.push(subscription); } } return allSubscriptions; }

This approach ensures you count subscriptions that are past due (customers who haven't paid but haven't been canceled) while excluding trials that haven't converted yet.

Calculating MRR from Individual Subscriptions

Now comes the core calculation logic. Each subscription can have multiple items (different products or tiers), and each needs proper normalization.

Basic MRR Calculation Function

function calculateSubscriptionMRR(subscription: Stripe.Subscription): number { let totalMRR = 0; for (const item of subscription.items.data) { const price = item.price; // Skip non-recurring prices if (!price.recurring) continue; const unitAmount = price.unit_amount || 0; // in cents const quantity = item.quantity || 1; // Calculate total for this billing period const periodTotal = (unitAmount * quantity) / 100; // convert to dollars // Normalize to monthly value const monthlyValue = normalizeToMonthly( periodTotal, price.recurring.interval, price.recurring.interval_count ); totalMRR += monthlyValue; } // Apply discounts if (subscription.discount) { totalMRR = applyDiscount(totalMRR, subscription.discount); } return totalMRR; }

Interval Normalization Logic

The normalization function handles different billing intervals:

function normalizeToMonthly( amount: number, interval: string, intervalCount: number ): number { switch (interval) { case 'month': return amount / intervalCount; case 'year': return amount / (12 * intervalCount); case 'week': // 52 weeks / 12 months = 4.33 weeks per month return (amount / intervalCount) * 4.33; case 'day': // Average days per month = 30.44 return (amount / intervalCount) * 30.44; default: throw new Error(`Unsupported interval: ${interval}`); } }

Important: Using 4.33 weeks per month and 30.44 days per month provides more accurate annualization than simple multiplication. These values account for the actual calendar structure.

Handling Discounts Correctly

Discounts in Stripe come in different forms, and each affects MRR differently:

function applyDiscount( mrr: number, discount: Stripe.Discount ): number { const coupon = discount.coupon; // Percentage discount if (coupon.percent_off) { return mrr * (1 - coupon.percent_off / 100); } // Fixed amount discount if (coupon.amount_off) { const discountAmount = coupon.amount_off / 100; // convert cents to dollars // For repeating discounts, we need to normalize like subscription amounts if (coupon.duration === 'repeating' && coupon.duration_in_months) { // This is tricky: Stripe applies the discount for X months // For MRR, we only count it while it's active return Math.max(0, mrr - discountAmount); } // Forever discounts apply indefinitely if (coupon.duration === 'forever') { return Math.max(0, mrr - discountAmount); } // 'once' discounts don't affect MRR since they're one-time return mrr; } return mrr; }

Edge case: One-time discounts (duration: 'once') shouldn't reduce MRR because they only apply to a single invoice. This is a common mistake that artificially deflates MRR calculations.

Handling Multi-Currency Subscriptions

If you operate internationally, you're dealing with multiple currencies. For accurate MRR reporting, you need to normalize everything to a base currency.

Currency Conversion Implementation

interface ExchangeRates { [currency: string]: number; } // Fetch current exchange rates (use a service like exchangerate-api.com) async function fetchExchangeRates(baseCurrency: string = 'USD'): Promise<ExchangeRates> { const response = await fetch( `https://api.exchangerate-api.com/v4/latest/${baseCurrency}` ); const data = await response.json(); return data.rates; } function convertToBaseCurrency( amount: number, fromCurrency: string, rates: ExchangeRates, baseCurrency: string = 'USD' ): number { if (fromCurrency === baseCurrency) return amount; // Convert to base currency const rate = rates[fromCurrency]; if (!rate) { throw new Error(`Exchange rate not found for ${fromCurrency}`); } return amount / rate; }

Calculating Multi-Currency MRR

async function calculateTotalMRR( subscriptions: Stripe.Subscription[], baseCurrency: string = 'USD' ): Promise<number> { const rates = await fetchExchangeRates(baseCurrency); let totalMRR = 0; for (const subscription of subscriptions) { const subscriptionMRR = calculateSubscriptionMRR(subscription); // Get currency from first price item const currency = subscription.items.data[0]?.price.currency || 'usd'; // Convert to base currency const normalizedMRR = convertToBaseCurrency( subscriptionMRR, currency.toUpperCase(), rates, baseCurrency ); totalMRR += normalizedMRR; } return totalMRR; }

Best practice: Cache exchange rates for the day rather than fetching them for every calculation. Exchange rates don't change frequently enough to warrant real-time fetching, and caching dramatically improves performance.

Tracking MRR Changes with Webhooks

Calculating MRR on-demand works, but for real-time tracking and historical analysis, you need to record MRR changes as they happen. This is where webhook implementation becomes critical.

Essential Webhook Events for MRR Tracking

const MRR_RELEVANT_EVENTS = [ 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', 'customer.subscription.trial_will_end', ] as const;

Webhook Handler Structure

import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; export async function POST(req: Request) { const body = await req.text(); const signature = headers().get('stripe-signature'); if (!signature) { return NextResponse.json({ error: 'No signature' }, { status: 400 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error('Webhook signature verification failed:', err); return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } // Handle MRR-relevant events if (MRR_RELEVANT_EVENTS.includes(event.type as any)) { await handleMRRChange(event); } return NextResponse.json({ received: true }); }

Recording MRR Changes

Create a database table to track MRR movements:

CREATE TABLE mrr_movements ( id SERIAL PRIMARY KEY, subscription_id VARCHAR(255) NOT NULL, customer_id VARCHAR(255) NOT NULL, event_type VARCHAR(50) NOT NULL, previous_mrr DECIMAL(10, 2), new_mrr DECIMAL(10, 2), mrr_change DECIMAL(10, 2), currency VARCHAR(3), occurred_at TIMESTAMP NOT NULL, metadata JSONB ); CREATE INDEX idx_mrr_movements_occurred_at ON mrr_movements(occurred_at); CREATE INDEX idx_mrr_movements_subscription ON mrr_movements(subscription_id);

MRR Change Handler Implementation

async function handleMRRChange(event: Stripe.Event) { const subscription = event.data.object as Stripe.Subscription; // Calculate current MRR const newMRR = calculateSubscriptionMRR(subscription); // Retrieve previous MRR from database const previousMRR = await getPreviousMRR(subscription.id); const mrrChange = newMRR - (previousMRR || 0); // Record the movement await recordMRRMovement({ subscriptionId: subscription.id, customerId: subscription.customer as string, eventType: event.type, previousMRR, newMRR, mrrChange, currency: subscription.items.data[0]?.price.currency || 'usd', occurredAt: new Date(event.created * 1000), metadata: { status: subscription.status, items: subscription.items.data.map(item => ({ priceId: item.price.id, quantity: item.quantity, })), }, }); // Update current MRR cache await updateCurrentMRR(subscription.id, newMRR); }

Categorizing MRR Movements

Different events represent different types of MRR changes, which is valuable for business analysis:

enum MRRMovementType { NEW = 'new', // New subscription EXPANSION = 'expansion', // Upgrade or add-on CONTRACTION = 'contraction', // Downgrade CHURN = 'churn', // Cancellation REACTIVATION = 'reactivation', // Resubscribe } function categorizeMRRMovement( eventType: string, previousMRR: number | null, newMRR: number, subscriptionStatus: Stripe.Subscription.Status ): MRRMovementType { // New subscription if (!previousMRR && newMRR > 0) { return MRRMovementType.NEW; } // Cancellation if (previousMRR > 0 && newMRR === 0) { return MRRMovementType.CHURN; } // Reactivation if (previousMRR === 0 && newMRR > 0) { return MRRMovementType.REACTIVATION; } // Expansion if (newMRR > previousMRR) { return MRRMovementType.EXPANSION; } // Contraction if (newMRR < previousMRR) { return MRRMovementType.CONTRACTION; } return MRRMovementType.NEW; // fallback }

This categorization enables powerful analytics like tracking expansion revenue separately from new customer acquisition, which are key SaaS metrics.

Handling Edge Cases and Common Pitfalls

Real-world subscription billing introduces complexity that breaks naive MRR calculations. Here are the critical edge cases you must handle.

Prorated Subscription Changes

When customers upgrade or downgrade mid-cycle, Stripe prorates the charges. However, proration affects the invoice, not MRR. Your MRR should reflect the new subscription value immediately:

function handleSubscriptionUpdate( event: Stripe.Event ): void { const subscription = event.data.object as Stripe.Subscription; const previousAttributes = event.data.previous_attributes as any; // Check if this is a plan change const planChanged = previousAttributes?.items?.data?.some( (item: any, index: number) => item.price.id !== subscription.items.data[index]?.price.id ); if (planChanged) { // Calculate new MRR immediately - ignore proration const newMRR = calculateSubscriptionMRR(subscription); // The proration credit/charge affects cash flow, not MRR recordMRRMovement({ subscriptionId: subscription.id, customerId: subscription.customer as string, eventType: 'plan_change', previousMRR: calculatePreviousMRR(previousAttributes), newMRR, mrrChange: newMRR - calculatePreviousMRR(previousAttributes), // ... other fields }); } }

Why this matters: If you include prorated amounts in MRR, you'll see artificial spikes and dips that don't reflect actual recurring revenue. MRR should change based on the new subscription value, not the prorated invoice amount.

Metered Billing and Usage-Based Pricing

Metered billing complicates MRR because revenue varies by usage. You have two approaches:

Approach 1: Exclude metered components (conservative)

function calculateSubscriptionMRR(subscription: Stripe.Subscription): number { let totalMRR = 0; for (const item of subscription.items.data) { const price = item.price; // Skip metered prices if (price.recurring?.usage_type === 'metered') { continue; } // Calculate only for licensed/fixed prices // ... rest of calculation } return totalMRR; }

Approach 2: Include predicted metered revenue (aggressive)

async function calculateMRRWithMetered( subscription: Stripe.Subscription ): Promise<number> { let totalMRR = 0; for (const item of subscription.items.data) { const price = item.price; if (price.recurring?.usage_type === 'metered') { // Calculate average usage over last 3 months const avgUsage = await getAverageUsage( subscription.id, item.id, 3 // months ); const predictedMRR = (price.unit_amount || 0) * avgUsage / 100; totalMRR += predictedMRR; } else { // Standard calculation for fixed prices totalMRR += calculateItemMRR(item); } } return totalMRR; }

Recommendation: Start with Approach 1 (conservative) for board reporting and investor metrics. Use Approach 2 for internal forecasting and capacity planning. The key is consistency—don't switch methodologies between reporting periods.

Subscriptions with Multiple Items

Stripe allows subscriptions with multiple price items. Each item might have different intervals:

// This is valid in Stripe but complicates MRR const subscription = await stripe.subscriptions.create({ customer: 'cus_xxx', items: [ { price: 'price_monthly_base' }, // $50/month { price: 'price_annual_addon' }, // $600/year ], });

Your MRR calculation must handle this:

function calculateSubscriptionMRR(subscription: Stripe.Subscription): number { let totalMRR = 0; // Each item normalizes independently for (const item of subscription.items.data) { const price = item.price; const unitAmount = price.unit_amount || 0; const quantity = item.quantity || 1; const periodTotal = (unitAmount * quantity) / 100; const monthlyValue = normalizeToMonthly( periodTotal, price.recurring!.interval, price.recurring!.interval_count ); totalMRR += monthlyValue; } return totalMRR; }

This correctly produces: $50 + ($600 / 12) = $100 MRR

Incomplete Subscriptions

Stripe creates subscriptions in an incomplete state when payment fails during creation. These should not count toward MRR:

async function fetchActiveSubscriptions(): Promise<Stripe.Subscription[]> { const subscriptions: Stripe.Subscription[] = []; for await (const subscription of stripe.subscriptions.list({ status: 'active', limit: 100, expand: ['data.items.data.price'], })) { // Double-check status (defensive programming) if (subscription.status === 'incomplete' || subscription.status === 'incomplete_expired') { continue; } subscriptions.push(subscription); } return subscriptions; }

Building an MRR Dashboard

With accurate MRR calculation and tracking in place, you can build a comprehensive dashboard. Here's a practical implementation using Next.js and React.

API Endpoint for MRR Metrics

// app/api/metrics/mrr/route.ts import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const startDate = searchParams.get('start'); const endDate = searchParams.get('end'); // Calculate current MRR const currentMRR = await calculateCurrentMRR(); // Get MRR movements for the period const movements = await getMRRMovements( startDate ? new Date(startDate) : undefined, endDate ? new Date(endDate) : undefined ); // Calculate growth metrics const metrics = calculateGrowthMetrics(movements); return NextResponse.json({ currentMRR, movements, metrics: { newMRR: metrics.new, expansionMRR: metrics.expansion, contractionMRR: metrics.contraction, churnedMRR: metrics.churned, netNewMRR: metrics.new + metrics.expansion - metrics.contraction - metrics.churned, }, }); } async function calculateCurrentMRR(): Promise<number> { const subscriptions = await fetchActiveSubscriptions(); const rates = await fetchExchangeRates('USD'); let total = 0; for (const sub of subscriptions) { const mrr = calculateSubscriptionMRR(sub); const currency = sub.items.data[0]?.price.currency || 'usd'; total += convertToBaseCurrency(mrr, currency.toUpperCase(), rates, 'USD'); } return total; }

MRR Growth Metrics

Key metrics for SaaS businesses:

interface MRRMetrics { new: number; expansion: number; contraction: number; churned: number; reactivation: number; netNewMRR: number; growthRate: number; } function calculateGrowthMetrics( movements: MRRMovement[], previousPeriodMRR: number ): MRRMetrics { const metrics: MRRMetrics = { new: 0, expansion: 0, contraction: 0, churned: 0, reactivation: 0, netNewMRR: 0, growthRate: 0, }; for (const movement of movements) { switch (movement.movementType) { case MRRMovementType.NEW: metrics.new += movement.mrrChange; break; case MRRMovementType.EXPANSION: metrics.expansion += movement.mrrChange; break; case MRRMovementType.CONTRACTION: metrics.contraction += Math.abs(movement.mrrChange); break; case MRRMovementType.CHURN: metrics.churned += Math.abs(movement.mrrChange); break; case MRRMovementType.REACTIVATION: metrics.reactivation += movement.mrrChange; break; } } metrics.netNewMRR = metrics.new + metrics.expansion + metrics.reactivation - metrics.contraction - metrics.churned; if (previousPeriodMRR > 0) { metrics.growthRate = (metrics.netNewMRR / previousPeriodMRR) * 100; } return metrics; }

Dashboard Component

'use client'; import { useEffect, useState } from 'react'; interface MRRData { currentMRR: number; metrics: { newMRR: number; expansionMRR: number; contractionMRR: number; churnedMRR: number; netNewMRR: number; }; } export function MRRDashboard() { const [data, setData] = useState<MRRData | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchData() { const response = await fetch('/api/metrics/mrr'); const json = await response.json(); setData(json); setLoading(false); } fetchData(); }, []); if (loading) return <div>Loading MRR metrics...</div>; if (!data) return <div>Failed to load metrics</div>; const formatCurrency = (amount: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(amount); }; return ( <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <MetricCard title="Current MRR" value={formatCurrency(data.currentMRR)} trend={data.metrics.netNewMRR > 0 ? 'up' : 'down'} /> <MetricCard title="New MRR" value={formatCurrency(data.metrics.newMRR)} description="From new customers" /> <MetricCard title="Expansion MRR" value={formatCurrency(data.metrics.expansionMRR)} description="From upgrades" /> <MetricCard title="Contraction MRR" value={formatCurrency(data.metrics.contractionMRR)} description="From downgrades" negative /> <MetricCard title="Churned MRR" value={formatCurrency(data.metrics.churnedMRR)} description="From cancellations" negative /> <MetricCard title="Net New MRR" value={formatCurrency(data.metrics.netNewMRR)} description="Total growth" trend={data.metrics.netNewMRR > 0 ? 'up' : 'down'} /> </div> ); }

Performance Optimization for Large Subscription Bases

As your business scales, calculating MRR for thousands of subscriptions becomes computationally expensive. Here are optimization strategies.

Caching Current MRR

Instead of calculating MRR from scratch each time, maintain a cached value:

// Database table CREATE TABLE mrr_cache ( subscription_id VARCHAR(255) PRIMARY KEY, current_mrr DECIMAL(10, 2) NOT NULL, currency VARCHAR(3) NOT NULL, last_updated TIMESTAMP NOT NULL );

Update the cache via webhooks:

async function updateCurrentMRR( subscriptionId: string, newMRR: number, currency: string ): Promise<void> { await db.query( `INSERT INTO mrr_cache (subscription_id, current_mrr, currency, last_updated) VALUES ($1, $2, $3, NOW()) ON CONFLICT (subscription_id) DO UPDATE SET current_mrr = $2, currency = $3, last_updated = NOW()`, [subscriptionId, newMRR, currency] ); }

Calculate total MRR from cache:

async function calculateCurrentMRRFromCache(): Promise<number> { const result = await db.query( `SELECT currency, SUM(current_mrr) as total FROM mrr_cache GROUP BY currency` ); const rates = await fetchExchangeRates('USD'); let totalMRR = 0; for (const row of result.rows) { const converted = convertToBaseCurrency( row.total, row.currency.toUpperCase(), rates, 'USD' ); totalMRR += converted; } return totalMRR; }

This reduces calculation time from O(n) API calls to O(1) database query.

Incremental Updates

Rather than recalculating everything, track only changes:

async function updateTotalMRR( subscriptionId: string, previousMRR: number, newMRR: number ): Promise<void> { const change = newMRR - previousMRR; // Update aggregate table await db.query( `UPDATE mrr_aggregate SET total_mrr = total_mrr + $1, last_updated = NOW() WHERE date = CURRENT_DATE`, [change] ); }

Batch Processing for Historical Analysis

For generating reports, batch process MRR movements:

async function generateMonthlyMRRReport( year: number, month: number ): Promise<MonthlyMRRReport> { const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month, 0); // Get all movements for the month const movements = await db.query( `SELECT DATE(occurred_at) as date, SUM(CASE WHEN mrr_change > 0 THEN mrr_change ELSE 0 END) as additions, SUM(CASE WHEN mrr_change < 0 THEN ABS(mrr_change) ELSE 0 END) as reductions FROM mrr_movements WHERE occurred_at >= $1 AND occurred_at <= $2 GROUP BY DATE(occurred_at) ORDER BY date`, [startDate, endDate] ); return { month, year, dailyBreakdown: movements.rows, totalAdditions: movements.rows.reduce((sum, row) => sum + row.additions, 0), totalReductions: movements.rows.reduce((sum, row) => sum + row.reductions, 0), }; }

Validating Your MRR Calculations

After implementing MRR tracking, you must validate accuracy. Here's a systematic approach.

Reconciliation with Stripe Revenue

async function reconcileMRRWithStripeRevenue( year: number, month: number ): Promise<ReconciliationReport> { // Your calculated MRR const calculatedMRR = await getMonthlyMRR(year, month); // Fetch actual revenue from Stripe for the month const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month, 0); let stripeRevenue = 0; for await (const invoice of stripe.invoices.list({ created: { gte: Math.floor(startDate.getTime() / 1000), lte: Math.floor(endDate.getTime() / 1000), }, status: 'paid', limit: 100, })) { stripeRevenue += invoice.amount_paid / 100; } // MRR should be close to monthly revenue for monthly subscriptions // For annual subscriptions, MRR will be 1/12 of annual revenue return { calculatedMRR, stripeRevenue, difference: calculatedMRR - stripeRevenue, percentDifference: ((calculatedMRR - stripeRevenue) / stripeRevenue) * 100, }; }

Expected discrepancies:

  • Annual subscriptions cause MRR to be lower than monthly revenue (expected)
  • Prorations cause monthly revenue to fluctuate (MRR should be stable)
  • One-time fees inflate monthly revenue (shouldn't affect MRR)

Automated Testing

Implement unit tests for edge cases:

describe('MRR Calculation', () => { it('correctly normalizes annual subscriptions', () => { const subscription = createMockSubscription({ amount: 120000, // $1,200 annual interval: 'year', }); const mrr = calculateSubscriptionMRR(subscription); expect(mrr).toBe(100); // $100/month }); it('handles multiple items with different intervals', () => { const subscription = createMockSubscription({ items: [ { amount: 5000, interval: 'month' }, // $50/month { amount: 60000, interval: 'year' }, // $600/year = $50/month ], }); const mrr = calculateSubscriptionMRR(subscription); expect(mrr).toBe(100); // $100/month total }); it('excludes one-time discounts from MRR', () => { const subscription = createMockSubscription({ amount: 10000, // $100/month interval: 'month', discount: { amount_off: 2000, // $20 off duration: 'once', }, }); const mrr = calculateSubscriptionMRR(subscription); expect(mrr).toBe(100); // Still $100 MRR }); });

Best Practices Summary

Implementing robust MRR tracking requires attention to detail and systematic approaches:

Data Collection:

  • Use Stripe's expand parameter to reduce API calls
  • Fetch all relevant subscription statuses (active, past_due, trialing)
  • Implement proper webhook handling for real-time updates
  • Cache exchange rates to improve performance

Calculation Logic:

  • Normalize all subscriptions to monthly equivalents using accurate conversion factors
  • Handle multi-currency subscriptions by converting to a base currency
  • Apply discounts correctly based on duration type
  • Exclude one-time charges and metered billing (or handle with predicted averages)
  • Calculate MRR per subscription item independently

Edge Cases:

  • Treat prorations as cash flow events, not MRR changes
  • Exclude incomplete subscriptions from MRR calculations
  • Handle subscriptions with multiple items at different intervals
  • Categorize MRR movements (new, expansion, contraction, churn) for analytics

Performance:

  • Maintain an MRR cache updated via webhooks
  • Use incremental updates rather than full recalculations
  • Batch process historical data for reports
  • Index database tables properly for fast queries

Validation:

  • Reconcile calculated MRR with Stripe revenue regularly
  • Implement automated tests for edge cases
  • Monitor for unexpected MRR fluctuations
  • Document your MRR calculation methodology for consistency

Conclusion

Accurate MRR tracking is foundational for subscription businesses, but Stripe doesn't calculate it for you. By implementing the patterns in this guide—proper subscription data retrieval, interval normalization, webhook-based tracking, and edge case handling—you'll have a reliable MRR system that scales with your business.

The key is treating MRR as a normalized metric that reflects committed recurring revenue, not cash collected. This distinction matters when dealing with annual subscriptions, prorations, and billing changes. Your MRR system should provide stability and predictability, smoothing out the irregularities of actual payment timing.

If you're building a subscription-based SaaS product and need expert help implementing comprehensive Stripe subscription billing, including MRR tracking, usage-based pricing, and customer portals, Osmoto specializes in exactly these implementations. We've built MRR tracking systems for numerous SaaS companies and can help you avoid the common pitfalls that lead to inaccurate metrics and poor business decisions.

For more on related topics, check out our guides on building a self-service customer portal and reducing churn with better payment failure handling.

Related Articles

Handling Failed Subscription Payments: A Complete Dunning Strategy
Subscription Billing
Handling Failed Subscription Payments: A Complete Dunning Strategy
When a customer's credit card expires or gets declined, you have roughly 7-10 days to recover that payment before they start considering alternatives. Yet most...
Stripe Subscription Trials: Free vs Paid Trials and Implementation
Subscription Billing
Stripe Subscription Trials: Free vs Paid Trials and Implementation
When a SaaS company launches their trial strategy, they face a critical decision that will impact conversion rates, customer quality, and long-term revenue: sho...
Building a Self-Service Customer Portal with Stripe Billing
Subscription Billing
Building a Self-Service Customer Portal with Stripe Billing
When a SaaS customer needs to update their payment method at 11 PM on a Sunday, they shouldn't have to wait until Monday morning to contact support. Yet many co...

Need Expert Implementation?

I provide professional Stripe integration and Next.js optimization services with fixed pricing and fast delivery.