# Don’t get burned, make your Stripe MRR boring and correct

> MRR is easy to calculate and easy to get subtly wrong. Here's how to normalize Stripe subscriptions, classify every movement, and reconcile your number with Stripe's own charts.

- Date: 2026-10-14T00:00:00.000Z
- Authors: Kirill Yakovenko, Valentin Kiselev, Travis Turner
- Categories: Developer Products
- URL: https://evilmartians.com/chronicles/dont-get-burned-make-your-stripe-mrr-boring-and-correct

---

MRR and the dynamics of its components are the metrics investors lean on to judge an early-stage company's growth and health (often before they read anything else in the update). Unfortunately, MRR is also the easiest metric to get subtly wrong. It looks trivial: sum the subscriptions, chart it, done. Then... a refund dents a month it shouldn't have touched, or an ordinary upgrade books itself as churn plus reactivation. Since the number looks correct, no one notices, and a quarter passes. But it's wrong, and it can steer you toward the wrong conclusions. We've been through this on dozens of client projects, analyzed every version of MRR calculations, and want to make sure you have a source of truth that makes the number boring, explainable, and safe to put in front of a board.

This guide is for engineers building or maintaining billing integration with Stripe, especially if you need MRR in your own dashboard/warehouse and want to understand it in detail. We'll focus on fixed-price subscriptions: how to calculate their monthly value, track changes, and reconcile the results. The examples use Ruby, but the same Stripe objects and rules apply in any language.

## One rule: normalize everything to monthly

[Monthly recurring revenue](https://docs.stripe.com/billing/subscriptions/analytics#billing-metric-definitions) or **MRR** is your current recurring subscription revenue as a monthly amount. Stripe’s starting point is subscriptions with a status of `active` or `past_due` (including those with overdue payments). 

Its standard calculation excludes taxes, free plans, and metered usage; trial items are excluded while their trial lasts, and discount treatment depends on your reporting settings. 

*Read also Evil Martians' article*: https://evilmartians.com/chronicles/payment-form-best-coding-practices-that-dont-drop-sales

For a fixed-price subscription, normalization means converting its recurring charge to a monthly equivalent. A $1,200/year plan contributes $100 in MRR, a $300/quarter plan contributes $100, and a $100/month plan contributes $100. **[ARR is MRR](https://support.stripe.com/questions/understanding-monthly-recurring-revenue-%28mrr%29-and-annual-recurring-revenue-%28arr%29) multiplied by 12.** 

Weekly plans need an explicit conversion convention because calendar months don’t contain a fixed number of weeks. One reasonable approach is to assume 52 weeks per year: multiply the weekly amount by 52 ÷ 12, or about 4.33 weeks per month. Under that convention, a $30/week plan contributes $130 in MRR. Document your choice and apply it consistently; if you need to match Stripe’s dashboard exactly, verify that your conversion produces the same results.

Whatever the billing interval, MRR is a snapshot of your subscription run rate at a particular moment. It describes the monthly value of subscriptions continuing on their current terms, rather than the cash collected during a month. A customer who pays $1,200 in January still contributes $100 in MRR in July, (as long as the subscription remains active and unchanged) even though no payment arrives from them that month.

## Where MRR moves in a subscription's life

These five stages help explain when MRR changes:

1. **Creation.** A new subscription usually starts as `incomplete`, waiting on its first payment, or `trialing`, and neither counts toward MRR.

2. **Activation.** When the status flips to `active`, MRR counting starts. The trigger is the status change, not the payment: a converting trial counts from the flip even if its first invoice later fails. If this is the customer's first paid subscription, that flip is new business; if they've paid before and are coming back from zero, it's reactivation. Stripe's own MRR behaves the same way.

3. **Renewal.** It moves nothing. An invoice paid every cycle is a cash event confirming a run rate you already counted.

4. **Modification.** Prices, quantities, items, and discounts change mid-life. This is where expansion and contraction live, and where most implementations go wrong.

5. **Termination.** An immediate cancellation is churn now. Setting `cancel_at_period_end` changes nothing today: the subscription stays active until the period closes and `customer.subscription.deleted` fires. Track scheduled churn as a separate leading indicator; don't subtract it early. A failed renewal isn't churn either: the subscription goes `past_due` and keeps counting while dunning runs, dropping out only if it ends in `canceled` or `unpaid`.

The resulting five movement categories give you the identity your chart must satisfy:

```
$$\Delta\mathrm{MRR} = \mathrm{new} + \mathrm{reactivation} + \mathrm{expansion} - \mathrm{contraction} - \mathrm{churn}$$
```

Add an FX term if you convert multiple billing currencies into one reporting currency (Stripe's growth chart carries one explicitly). If your movements don't sum to your delta, apart from rounding, there's an error in the data or calculation.

## Read subscription state instead of charges

Why not just sum successful charges? They mix recurring revenue with one-time fees, taxes, prorations, and annual prepays. 

>Payment timing and refunds can change cash flow without changing MRR.

Subscription objects carry the recurring terms: items, prices, billing intervals, and quantities. Normalize those, accounting for status, trials, discounts, and pricing rules.

These webhooks help identify MRR movements:

| Event | What it can mean for MRR |
| --- | --- |
| `customer.subscription.created` | Usually nothing yet: `incomplete` or `trialing` subscriptions don't count. |
| `customer.subscription.updated` | The workhorse: activation (`trialing` to `active`), expansion or contraction through item changes, and scheduled cancels. Diff `previous_attributes` to see what moved. |
| `customer.subscription.deleted` | Churn. |
| `customer.subscription.paused` / `.resumed` | Exit and re-entry via the `paused` status. |
| `invoice.payment_failed` | Start of dunning. MRR holds. |

Note what's not on that list: `invoice.paid`, `charge.succeeded`, and payout events. Those report cash movement, not run-rate change. Nothing in your MRR pipeline should subscribe to them.

## Four strategies to change a subscription

The same commercial change (say, moving a customer to a bigger plan) can be expressed several ways in Stripe's API. Your chart should classify the underlying MRR change consistently.

### 1. Update in place

To change a customer's plan, update the existing subscription item:

```ruby
Stripe::Subscription.update(
  subscription_id,
  items: [{ id: item_id, price: new_price_id }]
)
```

The same `items` array handles quantity changes, removals, and additions in one call. The subscription keeps its ID and billing schedule throughout, so every change lands as one clean, attributable delta: a simple expansion or contraction.

Two nuances: removing one of three items is a contraction, not churn, because the customer's aggregate stays positive; and under tiered pricing, a quantity change may move quantity without moving MRR. 

>Stripe's event stream models this explicitly; see the Sigma table below.

*Read also Evil Martians' article*: https://evilmartians.com/chronicles/value-pricing-for-dev-tools-a-strategy-beneficial-for-both-sides

A separate setting, `proration_behavior`, decides how the money for a mid-cycle change is collected. `create_prorations`, the default, adds an adjustment line to the next invoice; `always_invoice` charges the difference immediately; and `none` skips the adjustment. None of them affect MRR. Whichever you pick, the run rate moves the moment the items change, by the full difference between the old and new price.

Another important setting, `payment_behavior` specifies how the status of the payment affects the subscription status. Note: if you want to support 3DS flows you need to choose between `allow_incomplete` and `pending_if_incomplete`. The main difference between the two is that the first one moves subscription status to `past_due` on failure, still adjusting the expansion, so you need to deal with that manually, and the second one just cancels the update without affecting the MRR.

>**Our advice**: make this your default. 

This is what we ship in every billing integration, because each change stays on one subscription as a single attributable movement. Use `payment_behavior: "pending_if_incomplete"`; without it, the item updates immediately, and a bounced upgrade invoice books an expansion that no cash backs.

### 2. Cancel and recreate

The tempting choice to “just start fresh”, is easy to implement, and it also wrecks the chart. Every state-derived system, Stripe's included, sees exactly what you did: churn plus new business. Gross churn inflates, gross new inflates, net stays the same, and your logo-churn slide is now fiction.

Operationally, you also reset the billing cycle anchor, invite an immediate charge, and sever the subscription's history. The item-level variant has the same disease in miniature:

```ruby
items: [
  { id: old_item, deleted: true },
  { price: new_price }
]
```

The advice we give every client wiring up Stripe is to modify in place with `items: [{ id: old_item, price: new_price }]`, and save cancel-and-recreate for contracts that are truly new: a new legal entity, a new currency, or a deal renegotiated from scratch.

### 3. Pause and resume

Stripe has two unrelated pauses. `pause_collection` stops invoicing but leaves the status active, so Stripe's MRR (and any status-derived MRR) just keeps counting it. Decide whether paused-collection revenue is real. **We exclude it**: a deliberate, documented divergence from Stripe's chart.

The `paused` status (a trial that ended with no payment method, if you've configured that behavior) drops out of the active set on its own and needs no special handling.

### 4. Coupons and discounts

A permanent discount reduces MRR, so adding one counts as contraction and removing one counts as expansion. Stripe treats a reduced discount as expansion too, and always subtracts permanent recurring discounts from dashboard MRR.

One-time and fixed-duration discounts are controlled by a dashboard toggle that's off by default. Whichever treatment you pick, mirror your dashboard's configuration, or accept a permanent unexplained discrepancy  between the two numbers.

## Things that look like MRR but aren't

| Thing | Moves MRR? | Why |
| --- | --- | --- |
| Proration line items | No | Cash correction for a mid-cycle change. The run-rate delta was already counted at update time; proration line items exist only on invoices. |
| Free trials | No | `trialing` is excluded; MRR counts from the flip to `active`. |
| One-time charges | No | They aren't recurring. |
| Taxes | No | Tax isn't revenue. Tax-inclusive prices count in full; strip tax yourself if that matters. |
| Refunds and disputes | No | They're payment-level events. Churn only if you also cancel. |
| Failed payment / `past_due` | No, keeps counting | Dunning isn't churn until the subscription ends in `canceled` or `unpaid`. |
| Metered, usage-based items | No | Unpredictable isn't recurring. Stripe excludes them, and your baseline should too. |
| Multi-currency | Converts, doesn't move | Keep per-currency sums, convert at one consistent daily rate, and expect drift versus Stripe's estimated FX. |

## What you'll actually see in Stripe

So far, we've talked theory. Now, here's how these movements look in real Stripe output. A mid-cycle upgrade from a $10 to a $20 plan with default proration produces an updated subscription item, with the new price effective immediately. On the next invoice, it produces two extra lines: a credit for unused time on the old price and a charge for the remaining time on the new one, roughly -$5 and +$10 at mid-cycle, each flagged `proration: true`.

The Billing overview's MRR moves at update time by the full +$10, not the prorated cash. A converting trial arrives as `customer.subscription.updated` with status `active` and `trialing` in `previous_attributes`. A failed renewal shows `invoice.payment_failed` plus a `past_due` badge, while the dashboard keeps the subscription in MRR through dunning.

For audits, Stripe shows its work in Sigma / Stripe Data: the `subscription_item_change_events` table, and its fresher `_v2_beta` sibling, log one row per item change with an `mrr_change` amount in the item's currency:

| Event type | Meaning |
| --- | --- |
| `ACTIVE_START` / `ACTIVE_END` | An item began or stopped contributing to MRR. |
| `ACTIVE_UPGRADE` / `ACTIVE_DOWNGRADE` | An item's MRR rose or fell through a price or quantity change. |
| `ACTIVE_QUANTITY_INCREASE` / `_DECREASE` | Quantity moved, but MRR did not, as can happen with tiered pricing. |

If a movement in your system can't be matched to rows here, one of you is wrong, and now you can find out which.

One caveat before you go matching: the same event doesn't hit everywhere at once. The dashboard runs on Stripe's freshest pipeline; Sigma and warehouse exports trail it, each of which is on its own clock:

| Surface | Data appears after (typical) |
| --- | --- |
| Billing overview charts | ~1 hour |
| Revenue-tab report downloads | ~1 day |
| Sigma: API-backed tables (`subscriptions`, `invoices`, and so on) | ~3 hours |
| Sigma: `subscription_item_change_events` | Up to 24 hours |
| Sigma: `subscription_item_change_events_v2_beta` | ~3 hours, or ~7 hours via Data Pipeline to your warehouse |

Recent rows aren't final either, as Stripe can retroactively adjust the current and previous day's MRR events, and its own guidance is to let data settle for up to 48 hours before consuming it incrementally. If a movement hasn’t shown up yet, the data may still be catching up. Compare the same dates, and check today’s numbers again once the data has settled.

## Putting it to work: getting MRR right

Now, let's put all of this to work. The code below is conceptual, with a few common cases from the Ruby side of a billing integration. The goal is to feed Stripe the parameters that make every movement land on your chart as what it commercially was.

### Subscription updates

We already have active subscriptions. There are different options for what we can do with them:

```ruby
subscription_id = "sub_..."

# Upgrade to a new price/quantity.
Stripe::Subscription.update(
  subscription_id,
  items: [{ id: current_item_id, price: new_price_id, quantity: new_quantity }],
  proration_behavior: "always_invoice",
  payment_behavior: "pending_if_incomplete"
)

# Downgrade the subscription.
# Usually a scheduled change: the customer keeps what they paid for until the period ends.
# Updates the active schedule or creates a new one.
subscription = Stripe::Subscription.retrieve(subscription_id)
schedule = current_schedule(subscription)

Stripe::SubscriptionSchedule.update(
  schedule.id,
  phases: [
    {
      start_date: schedule.current_phase.start_date,
      end_date: schedule.current_phase.end_date,
      items: [{ price: current_price_id, quantity: current_quantity }]
    },
    {
      start_date: schedule.current_phase.end_date,
      items: [{ price: downgraded_price_id, quantity: downgraded_quantity }]
    }
  ]
)

def current_schedule(subscription)
  return Stripe::SubscriptionSchedule.create(from_subscription: subscription.id) unless subscription.schedule

  schedule = Stripe::SubscriptionSchedule.retrieve(subscription.schedule)
  return schedule if schedule_active?(schedule)

  Stripe::SubscriptionSchedule.release(schedule.id) if schedule.current_phase
  Stripe::SubscriptionSchedule.create(from_subscription: subscription.id)
end

def schedule_active?(schedule)
  phase = schedule&.phases&.first
  current_phase = schedule&.current_phase
  return false unless phase && current_phase

  phase.start_date == current_phase.start_date && phase.end_date == current_phase.end_date
end
```

## Trust, but reconcile

Your number and Stripe's Billing overview should agree closely, but reconcile on data older than 48 hours. Recent days are still settling on Stripe's side, and Sigma tables lag the dashboard by hours to a day. Compare per currency before any FX conversion, and expect every gap to trace to a documented policy difference: your `pause_collection` stance, discount toggles, or Stripe's estimated FX rates.

For correctness, use Stripe's test clocks. Create a clock-attached customer, advance time through trial, activation, upgrade, dunning, and cancellation, and assert that each transition produced exactly one movement of the right kind. Then add the assertion that guards everything:

```
$$\mathrm{new} + \mathrm{reactivation} + \mathrm{expansion} - \mathrm{contraction} - \mathrm{churn} = \Delta\mathrm{MRR}$$
```

---

If you're building your billing stack right now and would rather skip the trial and error, this is exactly the kind of engineering we do for startups every week. Drop us a line—we'll help you make MRR the most boring, trustworthy number in your investor update.

---

**Make your billing data trustworthy** Evil Martians help startups build billing systems that are accurate, explainable, and ready to scale. [Contact Evil Martians](https://evilmartians.com/contact-us)
