From 4a689c93a73a4b302d9faa0eebf48c2589245ebd Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:08:03 +0000 Subject: [PATCH 1/6] Add TTM revenue and tighten metric semantics --- CHANGELOG.md | 11 +- README.md | 269 +++++++++- .../profitable/dashboard_controller.rb | 1 + app/views/profitable/dashboard/index.html.erb | 4 + lib/profitable.rb | 222 +++++--- lib/profitable/mrr_calculator.rb | 6 +- .../processors/braintree_processor.rb | 2 +- .../processors/paddle_billing_processor.rb | 2 +- .../processors/paddle_classic_processor.rb | 2 +- test/mrr_calculator_test.rb | 99 ++++ test/profitable_test.rb | 476 +++++++++++++++++- test/test_helper.rb | 203 +++++--- 12 files changed, 1143 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8521302..645303c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # `profitable` +## [Unreleased] +- Add `Profitable.ttm_revenue` for trailing twelve-month revenue +- Add `Profitable.revenue_run_rate`, `estimated_arr_valuation`, `estimated_ttm_revenue_valuation`, and `estimated_revenue_run_rate_valuation` +- Make revenue metrics net of refunds when `amount_refunded` is present +- Make subscriber and MRR metrics distinguish between current billable subscriptions and historical period events +- Count `new_customers` from first monetization date rather than signup date +- Count `new_subscribers` / `new_mrr` from when a subscription becomes billable, not when a free trial starts +- Surface TTM revenue in the built-in dashboard + ## [0.4.0] - 2026-02-10 - Add monthly summary (12mo) and daily summary (30d) tables to dashboard - Add `period_data` method for efficient batch computation of period metrics @@ -32,4 +41,4 @@ ## [0.1.0] - 2024-08-29 -- Initial test release (not production ready) \ No newline at end of file +- Initial test release (not production ready) diff --git a/README.md b/README.md index 9d6d34b..869159f 100644 --- a/README.md +++ b/README.md @@ -52,25 +52,30 @@ All methods return numbers that can be converted to a nicely-formatted, human-re ### Revenue metrics -- `Profitable.mrr`: Monthly Recurring Revenue (MRR) -- `Profitable.arr`: Annual Recurring Revenue (ARR) -- `Profitable.all_time_revenue`: Total revenue since launch -- `Profitable.revenue_in_period(in_the_last: 30.days)`: Total revenue (recurring and non-recurring) in the specified period +- `Profitable.mrr`: Current monthly recurring run-rate from currently billable subscriptions +- `Profitable.arr`: Current annual recurring run-rate (`mrr * 12`), not trailing revenue +- `Profitable.ttm_revenue`: Trailing twelve-month revenue, net of refunds when `amount_refunded` is present +- `Profitable.revenue_run_rate(in_the_last: 30.days)`: Recent revenue annualized (useful for secondary TrustMRR-style revenue multiples) +- `Profitable.all_time_revenue`: Net revenue since launch +- `Profitable.revenue_in_period(in_the_last: 30.days)`: Net revenue (recurring and non-recurring) in the specified period - `Profitable.recurring_revenue_in_period(in_the_last: 30.days)`: Only recurring revenue in the specified period - `Profitable.recurring_revenue_percentage(in_the_last: 30.days)`: Percentage of revenue that is recurring in the specified period -- `Profitable.new_mrr(in_the_last: 30.days)`: New MRR added in the specified period +- `Profitable.new_mrr(in_the_last: 30.days)`: Full MRR from subscriptions that first became billable in the specified period - `Profitable.churned_mrr(in_the_last: 30.days)`: MRR lost due to churn in the specified period - `Profitable.average_revenue_per_customer`: Average revenue per customer (ARPC) - `Profitable.lifetime_value`: Estimated customer lifetime value (LTV) -- `Profitable.estimated_valuation(at: "3x")`: Estimated company valuation based on ARR +- `Profitable.estimated_valuation(at: "3x")`: Backwards-compatible ARR-based valuation heuristic +- `Profitable.estimated_arr_valuation(at: "3x")`: Explicit ARR-based valuation heuristic +- `Profitable.estimated_ttm_revenue_valuation(at: "2x")`: TTM revenue-based valuation heuristic +- `Profitable.estimated_revenue_run_rate_valuation(at: "2x", in_the_last: 30.days)`: Recent revenue run-rate valuation heuristic ### Customer metrics -- `Profitable.total_customers`: Total number of customers who have ever made a purchase or had a subscription (current and past) -- `Profitable.total_subscribers`: Total number of customers who have ever had a subscription (active or not) -- `Profitable.active_subscribers`: Number of customers with currently active subscriptions -- `Profitable.new_customers(in_the_last: 30.days)`: Number of new customers added in the specified period -- `Profitable.new_subscribers(in_the_last: 30.days)`: Number of new subscribers added in the specified period +- `Profitable.total_customers`: Total number of customers who have ever monetized through a paid charge or a paid subscription state +- `Profitable.total_subscribers`: Total number of customers who have ever reached a paid subscription state (trial-only subscriptions do not count) +- `Profitable.active_subscribers`: Number of customers with subscriptions that are billable right now +- `Profitable.new_customers(in_the_last: 30.days)`: Number of first-time customers added in the period based on first monetization date, not signup date +- `Profitable.new_subscribers(in_the_last: 30.days)`: Number of customers whose subscriptions first became billable in the specified period - `Profitable.churned_customers(in_the_last: 30.days)`: Number of customers who churned in the specified period ### Other metrics @@ -104,11 +109,21 @@ Profitable.churn(in_the_last: 3.months).to_readable # => "12%" Profitable.new_mrr(in_the_last: 24.hours).to_readable(2) # => "$123.45" # Get the estimated valuation at 5x ARR (defaults to 3x if no multiple is specified) -Profitable.estimated_valuation(multiple: 5).to_readable # => "$500,000" +Profitable.estimated_arr_valuation(multiple: 5).to_readable # => "$500,000" -# You can also pass the multiplier as a string. You can also use the `at:` keyword argument (same thing as `multiplier:`) – and/or ignore the `at:` or `multiplier:` named arguments altogether +# Get trailing twelve-month revenue +Profitable.ttm_revenue.to_readable # => "$123,456" + +# Get recent revenue annualized (useful for secondary TrustMRR-style revenue multiples) +Profitable.revenue_run_rate(in_the_last: 30.days).to_readable # => "$96,000" + +# `estimated_valuation` remains as a backwards-compatible alias of `estimated_arr_valuation` Profitable.estimated_valuation(at: "4.5x").to_readable # => "$450,000" +# Be explicit about the denominator when comparing marketplace comps +Profitable.estimated_ttm_revenue_valuation(2).to_readable +Profitable.estimated_revenue_run_rate_valuation(2.7, in_the_last: 30.days).to_readable + # Get the time to next MRR milestone Profitable.time_to_next_mrr_milestone.to_readable # => "26 days left to $10,000 MRR" ``` @@ -129,11 +144,239 @@ For more precise calculations, you can access the raw numeric value: Profitable.mrr # => 123456 ``` +Revenue methods are net of refunds when `amount_refunded` is present on `pay_charges`. + ### Notes on specific metrics - `mrr_growth_rate`: This calculation compares the MRR at the start and end of the specified period. It assumes a linear growth rate over the period, which may not reflect short-term fluctuations. For more accurate results, consider using shorter periods or implementing a more sophisticated growth calculation method if needed. - `time_to_next_mrr_milestone`: This estimation is based on the current MRR and the recent growth rate. It assumes a constant growth rate, which may not reflect real-world conditions. The calculation may be inaccurate for very new businesses or those with irregular growth patterns. +## Metric Guide: TTM, Revenue, Profit, ARR, and MRR + +`profitable` now exposes both run-rate metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose. + +These metrics are related, but they are not interchangeable: + +| Metric | What it means | Best for | What it is **not** | +| --- | --- | --- | --- | +| `MRR` | Current monthly recurring run-rate from active subscriptions | Operating cadence, near-term momentum, tracking upgrades/downgrades | Monthly cash collected from all sources | +| `ARR` | Current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | Historical last-12-month revenue | +| `MRR * 12` | Simple annualization of the current monthly recurring base | Fast ARR approximation when the base is normalized monthly | TTM revenue or TTM profit | +| `TTM revenue` | Actual revenue collected over the last 12 months | Buyer-facing historical actuals, smoothing seasonality, sanity-checking ARR | Forward recurring run-rate | +| `TTM profit` | Actual profit over the last 12 months | Small bootstrapped SaaS exits, ROI-minded buyers, earnings-based multiples | Something `profitable` can derive from `pay` alone | + +### The Distinction That Matters + +- `ARR` is a run-rate metric. Stripe describes it as revenue you "expect to earn in a year" and notes that `ARR = MRR × 12`. +- `TTM` is a trailing metric. CFI defines it as the "most recent 12-month period" and uses it for reported actuals such as revenue and EBITDA. +- `TTM revenue` tells you what customers actually paid over the last year. +- `TTM profit` tells you what the business actually kept after costs. This is often what smaller acquisition buyers care about most, but it requires cost data outside `pay`. +- In acquire-style market reports, `TTM` can refer to **both** `TTM profit` and `TTM revenue` depending on the chart. The denominator must always be stated explicitly. + +In other words: + +- `ARR` answers: "What is my current recurring run-rate?" +- `TTM revenue` answers: "What did I actually collect over the last year?" +- `TTM profit` answers: "What did I actually keep over the last year?" + +### What `profitable` Computes + +- `Profitable.mrr`: current monthly recurring run-rate from subscriptions that are billable right now +- `Profitable.arr`: current annual recurring run-rate +- `Profitable.ttm_revenue`: trailing 12-month revenue, net of refunds when `amount_refunded` is present +- `Profitable.revenue_run_rate`: recent revenue annualized to a yearly run-rate +- `Profitable.estimated_valuation`: a backwards-compatible ARR-multiple heuristic +- `Profitable.estimated_ttm_revenue_valuation`: TTM revenue heuristic +- `Profitable.estimated_revenue_run_rate_valuation`: recent revenue run-rate heuristic + +`profitable` does **not** calculate `TTM profit`, because payroll, contractor spend, hosting, support, software tools, taxes, and owner add-backs do not live inside `pay`. + +### Which Metric Matters in Which Situation? + +- If you're operating the business week to week: `MRR` is usually the best pulse metric. +- If you want to understand your current subscription run-rate: `ARR` is the right metric. +- If you're preparing buyer materials for a bootstrapped SaaS exit: add `TTM revenue` and your own `TTM profit`. +- If your business has meaningful one-time revenue, services, setup fees, or seasonal swings: `TTM revenue` matters more than `ARR`. +- If you are speaking to serious SaaS buyers about revenue quality: pair `ARR` with churn, growth, concentration, and margins. + +### Source Hierarchy + +This README treats `Acquire.com` as the primary valuation reference for smaller SaaS exits. + +`TrustMRR` is still useful, but as a secondary reference for how very small SaaS listings are packaged, normalized, and displayed in-market. + +### What Current Market Sources Say + +These are short excerpts from current market and finance sources, followed by why they matter for `profitable`. + +Primary valuation reference: + +- [Acquire.com Biannual Multiples Report (Jan 2026)](https://blog.acquire.com/acquire-com-biannual-acquisition-multiples-report-jan-2026/): "anchor valuation on profit" + Acquire says the January 2026 report is focused "entirely on profit multiples," which is highly relevant for smaller bootstrapped SaaS exits. + In the same report, some visual breakdowns segment businesses by `TTM revenue` bands, so it is important not to assume one bare `TTM` label means the same thing everywhere. + +- [Acquire.com Biannual Multiples Report, January 2024](https://blog.acquire.com/wp-content/uploads/2024/01/Acquire-Biannual-Multiples-Report-Jan-2024.pdf): "4.3x TTM profit" + The earlier report gives a concrete historical benchmark for how these profit multiples looked on the marketplace. + +- [Acquire.com 2025 webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/): "$100k-$1M in TTM revenue" + Acquire says that cohort averaged `4.35x`, which is a useful live-market anchor for micro-SaaS exits. + +- [Acquire.com SaaS valuation multiples guide](https://blog.acquire.com/saas-valuation-multiples/): "5x to 15x ARR" + Stronger recurring SaaS businesses are also routinely discussed in ARR-multiple terms, especially when growth and retention are strong. + +- [Stripe on ARR](https://stripe.com/us/resources/more/acv-vs-arr-what-each-metric-really-means-and-when-they-matter): "ARR = £50,000 x 12 = £600,000" + This is the clearest shorthand for why `ARR` is a run-rate, not a trailing actual. + +- [CFI on TTM](https://corporatefinanceinstitute.com/resources/valuation/railing-twelve-months-ttm-definition/): "most recent 12-month period" + This is why `ttm_revenue` belongs beside `arr`: it measures trailing actuals, not a projection. + +- [Quiet Light on selling SaaS](https://quietlight.com/sell-your-saas-business-for-the-best-price/): "EBITDA or SDE" + Quiet Light explicitly says smaller SaaS businesses are often valued on earnings, not revenue, which is why `TTM profit` matters. + +- [Quiet Light on larger SaaS](https://quietlight.com/sell-your-saas-business-for-the-best-price/): "ARR of $1M or more" + The same source says larger SaaS businesses may qualify for revenue multiples, which is why `ARR` becomes more important as the business scales. + +- [Software Equity Group, 3Q25 SaaS M&A](https://softwareequity.com/blog/saas-ma-deal-volume-and-valuations): "5.4x" + SEG reported average SaaS M&A valuations of `5.4x` revenue in 3Q25, which is useful context for larger, more institutional software transactions. + +Secondary listing-practice reference: + +- [TrustMRR live listing example](https://trustmrr.com/startup/appalchemy): "$164,819 TTM revenue" + Live marketplaces increasingly show `TTM revenue`, `TTM profit`, and `ARR` side by side, which matches how buyers actually compare deals. + +- [TrustMRR FAQ](https://trustmrr.com/faq): "asking price divided by annualized revenue" + TrustMRR explicitly defines its marketplace multiple as asking price divided by `last 30 days revenue × 12`, so its multiple is a revenue run-rate multiple, not an ARR multiple. + +- [TrustMRR FAQ](https://trustmrr.com/faq): "Only aggregate revenue metrics" + TrustMRR says it only pulls revenue-level aggregates from payment providers, which is another reason its native multiple is revenue-based rather than profit-based. + +- [TrustMRR FAQ](https://trustmrr.com/faq): "profit margin for the last 30 days" + TrustMRR asks sellers to provide profit margin separately when listing for sale, which reinforces that profit-based heuristics need cost inputs outside the payment provider. + +### How to Use These Metrics Responsibly + +- `estimated_valuation` is intentionally simple. It is kept as a backwards-compatible ARR heuristic. Prefer `estimated_arr_valuation` in new code when you want the denominator to be explicit. +- Do not compare an ARR multiple and a TTM profit multiple as if they were the same kind of number. They are based on different denominators. +- A `4x TTM profit` deal, a `2x TTM revenue` deal, and an `8x ARR` deal can all describe reasonable SaaS outcomes in different buyer segments. +- If two businesses both have `$300k ARR`, the one with lower churn, better margins, lower concentration, and cleaner growth usually deserves the higher multiple. +- If two businesses both have `$300k TTM revenue`, the one with stronger profit and more recurring revenue usually deserves the higher price. + +### Typical Multiples by SaaS Type and Size + +These are rough, source-backed heuristics. They are not interchangeable. + +| SaaS profile | Common denominator | Rough multiple | Source | +| --- | --- | --- | --- | +| Smaller profitable SaaS on Acquire.com (2024-2025 confirmed transactions) | `TTM profit` | `3.9x` median | [Acquire.com Jan 2026 report](https://blog.acquire.com/acquire-com-biannual-acquisition-multiples-report-jan-2026/) | +| Micro-SaaS under `$100k` TTM revenue | `TTM profit` | `3.55x` average | [Acquire.com webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/) | +| Micro-SaaS with `$100k-$1M` TTM revenue | `TTM profit` | `4.35x` average | [Acquire.com webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/) | +| Secondary TrustMRR marketplace listings | `Annualized last 30d revenue` | often roughly `0.6x-5.5x` ask multiples | [TrustMRR homepage snapshot](https://trustmrr.com/) | +| Mid-6-figure ARR SaaS | `TTM revenue` | `2x-4x` revenue | [Acquire.com founder-driven acquisition recap](https://blog.acquire.com/how-founders-can-drive-their-own-acquisition-process-webinar-recap/) | +| Older Acquire.com SaaS baseline | `TTM revenue` or `TTM profit` | `2-3x revenue` or `5x profit` | [Acquire.com 7-8 figures webinar recap](https://blog.acquire.com/how-to-sell-your-company-playbook-webinar/) | +| Strong recurring SaaS with high growth and retention | `ARR` | `5x-15x ARR` | [Acquire.com SaaS valuation multiples guide](https://blog.acquire.com/saas-valuation-multiples/) | + +How to read this table: + +- Smaller bootstrapped SaaS buyers on Acquire-style marketplaces often underwrite on `TTM profit`. +- If profit is low or intentionally reinvested, buyers may fall back to `TTM revenue`. +- TrustMRR listing multiples are a secondary comparison set: they are based on recent revenue run-rate, specifically `last 30 days revenue × 12`. +- Higher-quality SaaS with real scale, low churn, and strong growth is more likely to be discussed in `ARR` terms. + +### Rough Valuation Formulas from `profitable` + +You can only multiply a metric by a multiple if the denominator matches. + +#### 1. ARR multiple + +This is already built into the gem: + +```ruby +# Example: 6x ARR +Profitable.estimated_arr_valuation(multiple: 6).to_readable +``` + +Use this when: + +- your business is strongly recurring, +- churn and retention are solid, +- and you want a run-rate-based heuristic. + +#### 2. TTM revenue multiple + +This is useful when buyers care more about trailing actuals than annualized run-rate: + +```ruby +ttm_revenue_cents = Profitable.ttm_revenue.to_i + +low_estimate_cents = (ttm_revenue_cents * 2.0).round +high_estimate_cents = (ttm_revenue_cents * 4.0).round +``` + +Use this when: + +- the business has meaningful one-time revenue, +- profit is thin because you are reinvesting, +- or the buyer is thinking in revenue-band terms. + +#### 2b. Recent revenue run-rate multiple + +This is the closest match to secondary TrustMRR-style marketplace multiples: + +```ruby +# Default: annualized last-30-days revenue +Profitable.revenue_run_rate.to_readable +Profitable.estimated_revenue_run_rate_valuation(2.7).to_readable +``` + +Use this when: + +- you're comparing against TrustMRR listings, +- the market is quoting a multiple on recent revenue rather than ARR, +- and you want the denominator to match the marketplace comp. + +#### 3. TTM profit multiple + +`profitable` cannot calculate this yet because it does not know your costs. + +```ruby +# You need to compute this outside of profitable: +ttm_profit_cents = your_ttm_profit_cents + +low_estimate_cents = (ttm_profit_cents * 3.5).round +high_estimate_cents = (ttm_profit_cents * 4.35).round +``` + +Use this when: + +- the business is a smaller profitable micro-SaaS, +- the buyer is focused on ROI and cash flow, +- or you're comparing yourself to Acquire.com-style marketplace comps. + +#### A Practical Rule + +If you are unsure which denominator the buyer is using, ask explicitly: + +- "Are you thinking in TTM profit, TTM revenue, or ARR?" + +That one question avoids a huge amount of confusion. + +### RailsFast-Based Businesses + +There is no special published "RailsFast multiple." + +Inference from the sources above: a RailsFast-based SaaS should generally be valued like any other SaaS with similar: + +- TTM revenue +- TTM profit +- ARR / MRR quality +- churn and retention +- growth rate +- customer concentration +- margins +- transferability and documentation + +The stack is usually secondary to business quality. What the stack can do indirectly is improve margins, speed of execution, and transferability, which can help the business earn a better multiple over time. + ## Development After checking out the repo, install dependencies: diff --git a/app/controllers/profitable/dashboard_controller.rb b/app/controllers/profitable/dashboard_controller.rb index 38d6efd..77dea5a 100644 --- a/app/controllers/profitable/dashboard_controller.rb +++ b/app/controllers/profitable/dashboard_controller.rb @@ -4,6 +4,7 @@ def index @mrr = Profitable.mrr @mrr_growth_rate = Profitable.mrr_growth_rate @total_customers = Profitable.total_customers + @ttm_revenue = Profitable.ttm_revenue @all_time_revenue = Profitable.all_time_revenue @estimated_valuation = Profitable.estimated_valuation @average_revenue_per_customer = Profitable.average_revenue_per_customer diff --git a/app/views/profitable/dashboard/index.html.erb b/app/views/profitable/dashboard/index.html.erb index 65feff1..6b5c55d 100644 --- a/app/views/profitable/dashboard/index.html.erb +++ b/app/views/profitable/dashboard/index.html.erb @@ -53,6 +53,10 @@

<%= @mrr.to_readable %>

MRR

+
+

<%= @ttm_revenue.to_readable %>

+

TTM revenue

+

<%= @estimated_valuation.to_readable %>

Valuation at 3x ARR

diff --git a/lib/profitable.rb b/lib/profitable.rb index ed924e6..9e768b6 100644 --- a/lib/profitable.rb +++ b/lib/profitable.rb @@ -14,8 +14,9 @@ module Profitable # Subscription status constants (at module level so MrrCalculator can reference them) - EXCLUDED_STATUSES = ['trialing', 'paused'].freeze CHURNED_STATUSES = ['canceled', 'ended'].freeze + NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze + CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES = (CHURNED_STATUSES + ['trialing', 'paused'] + NEVER_BILLABLE_SUBSCRIPTION_STATUSES).freeze class << self include ActionView::Helpers::NumberHelper @@ -24,10 +25,14 @@ class << self DEFAULT_PERIOD = 30.days MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000] + # Current monthly recurring run-rate from active subscriptions. + # Useful for operating momentum and near-term subscription changes. def mrr NumericResult.new(MrrCalculator.calculate) end + # Current annual recurring run-rate. + # This is today's recurring base annualized, not historical 12-month revenue. def arr NumericResult.new(calculate_arr) end @@ -40,6 +45,14 @@ def all_time_revenue NumericResult.new(calculate_all_time_revenue) end + # Trailing twelve-month revenue reflects actual cash collected in the last year. + # It complements ARR, which annualizes the current recurring base. + def ttm_revenue + revenue_in_period(in_the_last: 12.months) + end + + # Historical revenue collected over a rolling period. + # Unlike ARR, this is trailing actual revenue rather than a projection. def revenue_in_period(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_revenue_in_period(in_the_last)) end @@ -52,27 +65,56 @@ def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) end + def revenue_run_rate(in_the_last: 30.days) + NumericResult.new(calculate_revenue_run_rate(in_the_last)) + end + + # Backwards-compatible ARR-multiple heuristic for a quick valuation estimate. + # This is intentionally simple and should not be treated as a market appraisal. def estimated_valuation(multiplier = nil, at: nil, multiple: nil) + estimated_arr_valuation(multiplier, at:, multiple:) + end + + def estimated_arr_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(arr.to_i, actual_multiplier)) + end + + def estimated_ttm_revenue_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier)) + end + + def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days) actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation(actual_multiplier)) + NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier)) end + # Customers who have actually monetized: either a paid charge or a subscription + # that has crossed into a billable state. def total_customers NumericResult.new(calculate_total_customers, :integer) end + # Customers who have ever had a paid subscription. Trial-only subscriptions do not count. def total_subscribers NumericResult.new(calculate_total_subscribers, :integer) end + # Customers with subscriptions that are billable right now. + # Excludes free trials, paused subscriptions, and churned subscriptions. def active_subscribers NumericResult.new(calculate_active_subscribers, :integer) end + # First-time customers added in the period, based on first monetization date + # rather than signup date. def new_customers(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_new_customers(in_the_last), :integer) end + # Customers whose subscriptions first became billable in the period. + # Trial starts do not count until the trial ends. def new_subscribers(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_new_subscribers(in_the_last), :integer) end @@ -81,6 +123,8 @@ def churned_customers(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_churned_customers(in_the_last), :integer) end + # Full monthly value of subscriptions that became billable in the period. + # This is a flow metric, so it still counts subscriptions that churned later in the same window. def new_mrr(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_new_mrr(in_the_last)) end @@ -149,6 +193,47 @@ def subscriptions_with_processor(scope = Pay::Subscription.all) .joins(:customer) end + def subscription_became_billable_at_sql + 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)' + end + + def subscription_is_billable_by(date, scope = Pay::Subscription.all) + scope.where( + "(pay_subscriptions.status != ? OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", + 'trialing', + date + ) + end + + def ever_billable_subscription_scope(scope = Pay::Subscription.all) + subscription_is_billable_by(Time.current, scope) + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where("#{subscription_became_billable_at_sql} <= ?", Time.current) + end + + def billable_subscription_scope_at(date, scope = Pay::Subscription.all) + subscription_is_billable_by(date, scope) + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where("#{subscription_became_billable_at_sql} <= ?", date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) + .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) + end + + def current_billable_subscription_scope(scope = Pay::Subscription.all) + billable_subscription_scope_at(Time.current, scope) + .where.not(status: CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES) + end + + def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) + subscription_is_billable_by(period_end, scope) + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) + end + + def subscription_became_billable_at(subscription) + subscription.trial_ends_at || subscription.created_at + end + def paid_charges # Pay gem v10+ stores charge data in `object` column, older versions used `data` # We check both columns for backwards compatibility using database-agnostic JSON extraction @@ -179,8 +264,18 @@ def paid_charges SQL end + # Revenue metrics should reflect net cash collected, not gross billed amounts. + # When Pay stores refunded cents on the charge, subtract them from revenue. + def net_revenue(scope) + scope.sum(net_charge_amount_sql) + end + + def net_charge_amount_sql + "pay_charges.amount - COALESCE(pay_charges.amount_refunded, 0)" + end + def calculate_all_time_revenue - paid_charges.sum(:amount) + net_revenue(paid_charges) end def calculate_arr @@ -188,8 +283,12 @@ def calculate_arr end def calculate_estimated_valuation(multiplier = 3) + calculate_estimated_valuation_from(calculate_arr, multiplier) + end + + def calculate_estimated_valuation_from(base_amount, multiplier = 3) multiplier = parse_multiplier(multiplier) - (calculate_arr * multiplier).round + (base_amount * multiplier).round end def parse_multiplier(input) @@ -224,14 +323,24 @@ def calculate_new_mrr(period = DEFAULT_PERIOD) end def calculate_revenue_in_period(period) - paid_charges.where(created_at: period.ago..Time.current).sum(:amount) + net_revenue(paid_charges.where(created_at: period.ago..Time.current)) + end + + def calculate_revenue_run_rate(period) + return 0 if period.to_i <= 0 + + # TrustMRR-style revenue multiples are usually quoted against recent monthly + # revenue annualized, so we normalize to a 30-day month and multiply by 12. + monthly_revenue = calculate_revenue_in_period(period).to_f * (30.days.to_f / period.to_f) + (monthly_revenue * 12).round end def calculate_recurring_revenue_in_period(period) - paid_charges - .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') - .where(created_at: period.ago..Time.current) - .sum(:amount) + net_revenue( + paid_charges + .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') + .where(created_at: period.ago..Time.current) + ) end def calculate_recurring_revenue_percentage(period) @@ -244,32 +353,39 @@ def calculate_recurring_revenue_percentage(period) end def calculate_total_customers - Pay::Customer.joins(:charges) - .merge(paid_charges) - .distinct - .count + actual_customers.count end def calculate_total_subscribers - Pay::Customer.joins(:subscriptions).distinct.count + ever_billable_subscription_scope.distinct.count(:customer_id) end def calculate_active_subscribers - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { status: 'active' }) - .distinct - .count + current_billable_subscription_scope.distinct.count(:customer_id) end def actual_customers - Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id") - .joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id") - .where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0") - .distinct + customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id)) + customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id)) + + customers_with_paid_charges.or(customers_with_billable_subscriptions).distinct end def calculate_new_customers(period) - actual_customers.where(created_at: period.ago..Time.current).count + period_start = period.ago + period_end = Time.current + + first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at) + first_subscription_dates = ever_billable_subscription_scope + .group(:customer_id) + .minimum(Arel.sql(subscription_became_billable_at_sql)) + + customer_ids = first_charge_dates.keys | first_subscription_dates.keys + + customer_ids.count do |customer_id| + first_customer_date = [first_charge_dates[customer_id], first_subscription_dates[customer_id]].compact.min + first_customer_date && first_customer_date >= period_start && first_customer_date <= period_end + end end def calculate_new_subscribers(period) @@ -314,16 +430,12 @@ def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) def calculate_mrr_at(date) # Find subscriptions that were active AT the given date: - # - Created before or on that date + # - Started billing before or on that date # - Not ended before that date (ends_at is nil OR ends_at > date) # - Not paused at that date - # - Not in trialing status (trials don't count as MRR) + # - Not still in a free trial at that date subscriptions_with_processor( - Pay::Subscription - .where('pay_subscriptions.created_at <= ?', date) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) - .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) - .where.not(status: EXCLUDED_STATUSES) + billable_subscription_scope_at(date) ).sum do |subscription| MrrCalculator.process_subscription(subscription) end @@ -333,17 +445,14 @@ def calculate_period_data(period) period_start = period.ago period_end = Time.current - new_customers_count = actual_customers.where(created_at: period_start..period_end).count + new_customers_count = calculate_new_customers(period) churned_count = calculate_churned_subscribers_in_period(period_start, period_end) new_mrr_val = calculate_new_mrr_in_period(period_start, period_end) churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end) - revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount) + revenue_val = net_revenue(paid_charges.where(created_at: period_start..period_end)) # Churn rate (reuses churned_count) - total_at_start = Pay::Subscription - .where('pay_subscriptions.created_at < ?', period_start) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start) - .where.not(status: EXCLUDED_STATUSES) + total_at_start = billable_subscription_scope_at(period_start) .distinct .count('customer_id') churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 @@ -366,9 +475,8 @@ def calculate_monthly_summary(months_count) # Bulk load all data for the full range new_sub_records = Pay::Subscription - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) + .merge(billable_subscription_events_in_period(overall_start, overall_end)) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) churned_sub_records = Pay::Subscription .where(status: CHURNED_STATUSES) @@ -376,9 +484,7 @@ def calculate_monthly_summary(months_count) .pluck(:customer_id, :ends_at) new_mrr_subs = subscriptions_with_processor( - Pay::Subscription - .where(status: 'active') - .where(created_at: overall_start..overall_end) + billable_subscription_events_in_period(overall_start, overall_end) ).to_a churned_mrr_subs = subscriptions_with_processor( @@ -387,11 +493,9 @@ def calculate_monthly_summary(months_count) .where(ends_at: overall_start..overall_end) ).to_a - churn_base_records = Pay::Subscription - .where('pay_subscriptions.created_at < ?', overall_end) + churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription) .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at, :ends_at) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at) # Group by month in Ruby summary = [] @@ -408,7 +512,7 @@ def calculate_monthly_summary(months_count) .map(&:first).uniq.count new_mrr_amount = new_mrr_subs - .select { |s| s.created_at >= month_start && s.created_at <= month_end } + .select { |s| subscription_became_billable_at(s) >= month_start && subscription_became_billable_at(s) <= month_end } .sum { |s| MrrCalculator.process_subscription(s) } churned_mrr_amount = churned_mrr_subs @@ -416,7 +520,7 @@ def calculate_monthly_summary(months_count) .sum { |s| MrrCalculator.process_subscription(s) } total_at_start = churn_base_records - .select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) } + .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) } .map(&:first).uniq.count churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 @@ -443,9 +547,8 @@ def calculate_daily_summary(days_count) overall_end = Time.current.end_of_day new_sub_records = Pay::Subscription - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) + .merge(billable_subscription_events_in_period(overall_start, overall_end)) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) churned_sub_records = Pay::Subscription .where(status: CHURNED_STATUSES) @@ -477,11 +580,9 @@ def calculate_daily_summary(days_count) # Consolidated methods that work with any date range def calculate_new_subscribers_in_period(period_start, period_end) - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { created_at: period_start..period_end }) - .where.not(pay_subscriptions: { status: EXCLUDED_STATUSES }) - .distinct - .count + billable_subscription_events_in_period(period_start, period_end) + .distinct + .count(:customer_id) end def calculate_churned_subscribers_in_period(period_start, period_end) @@ -494,9 +595,7 @@ def calculate_churned_subscribers_in_period(period_start, period_end) def calculate_new_mrr_in_period(period_start, period_end) subscriptions_with_processor( - Pay::Subscription - .where(status: 'active') - .where(created_at: period_start..period_end) + billable_subscription_events_in_period(period_start, period_end) ).sum do |subscription| MrrCalculator.process_subscription(subscription) end @@ -514,10 +613,7 @@ def calculate_churned_mrr_in_period(period_start, period_end) def calculate_churn_rate_for_period(period_start, period_end) # Count subscribers who were active AT the start of the period - total_subscribers_start = Pay::Subscription - .where('pay_subscriptions.created_at < ?', period_start) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start) - .where.not(status: EXCLUDED_STATUSES) + total_subscribers_start = billable_subscription_scope_at(period_start) .distinct .count('customer_id') diff --git a/lib/profitable/mrr_calculator.rb b/lib/profitable/mrr_calculator.rb index a35888c..04f670f 100644 --- a/lib/profitable/mrr_calculator.rb +++ b/lib/profitable/mrr_calculator.rb @@ -9,8 +9,10 @@ class MrrCalculator def self.calculate total_mrr = 0 subscriptions = Pay::Subscription - .active - .where.not(status: Profitable::EXCLUDED_STATUSES) + .where.not(status: Profitable::CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES) + .where('COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at) <= ?', Time.current) + .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', Time.current) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', Time.current) .includes(:customer) .select('pay_subscriptions.*, pay_customers.processor as customer_processor') .joins(:customer) diff --git a/lib/profitable/processors/braintree_processor.rb b/lib/profitable/processors/braintree_processor.rb index 7af410b..ac21bf0 100644 --- a/lib/profitable/processors/braintree_processor.rb +++ b/lib/profitable/processors/braintree_processor.rb @@ -12,7 +12,7 @@ def calculate_mrr interval = data['billing_period_unit'] interval_count = data['billing_period_frequency'] || 1 - normalize_to_monthly(amount * quantity, interval, interval_count) + normalize_to_monthly(amount.to_f * quantity, interval, interval_count) end end end diff --git a/lib/profitable/processors/paddle_billing_processor.rb b/lib/profitable/processors/paddle_billing_processor.rb index 429d291..10ec19b 100644 --- a/lib/profitable/processors/paddle_billing_processor.rb +++ b/lib/profitable/processors/paddle_billing_processor.rb @@ -22,7 +22,7 @@ def calculate_mrr interval = price_data.dig('billing_cycle', 'interval') interval_count = price_data.dig('billing_cycle', 'frequency') - total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count) + total_mrr += normalize_to_monthly(amount.to_f * item_quantity, interval, interval_count) end total_mrr diff --git a/lib/profitable/processors/paddle_classic_processor.rb b/lib/profitable/processors/paddle_classic_processor.rb index 7a21478..f178950 100644 --- a/lib/profitable/processors/paddle_classic_processor.rb +++ b/lib/profitable/processors/paddle_classic_processor.rb @@ -12,7 +12,7 @@ def calculate_mrr interval = data['recurring_interval'] interval_count = 1 # Paddle Classic doesn't have interval_count - normalize_to_monthly(amount * quantity, interval, interval_count) + normalize_to_monthly(amount.to_f * quantity, interval, interval_count) end end end diff --git a/test/mrr_calculator_test.rb b/test/mrr_calculator_test.rb index 3c30f7b..37a0494 100644 --- a/test/mrr_calculator_test.rb +++ b/test/mrr_calculator_test.rb @@ -84,6 +84,18 @@ def test_calculate_excludes_trialing_subscriptions assert_equal 0, Profitable::MrrCalculator.calculate end + def test_calculate_excludes_active_subscriptions_still_on_trial + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + subscription.update!(trial_ends_at: 5.days.from_now) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + def test_calculate_excludes_paused_subscriptions create_stripe_subscription_v10( customer: @stripe_customer, @@ -95,6 +107,29 @@ def test_calculate_excludes_paused_subscriptions assert_equal 0, Profitable::MrrCalculator.calculate end + def test_calculate_excludes_incomplete_unpaid_and_incomplete_expired_subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "incomplete" + ) + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "unpaid" + ) + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 2900, + interval: "month", + status: "incomplete_expired" + ) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + def test_calculate_excludes_canceled_subscriptions create_stripe_subscription_v10( customer: @stripe_customer, @@ -117,6 +152,37 @@ def test_calculate_excludes_ended_subscriptions assert_equal 0, Profitable::MrrCalculator.calculate end + def test_calculate_includes_past_due_subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "past_due" + ) + + assert_equal 9900, Profitable::MrrCalculator.calculate + end + + def test_calculate_includes_active_subscriptions_until_future_pause_or_end_date + pausing_subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 5000, + interval: "month", + status: "active" + ) + pausing_subscription.update!(pause_starts_at: 5.days.from_now) + + ending_subscription = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 3000, + interval: "month", + status: "active" + ) + ending_subscription.update!(ends_at: 5.days.from_now) + + assert_equal 8000, Profitable::MrrCalculator.calculate + end + def test_calculate_includes_only_active_subscriptions # Active: $50 create_stripe_subscription_v10( @@ -169,6 +235,17 @@ def test_process_subscription_routes_to_braintree_processor assert_equal 4900, Profitable::MrrCalculator.process_subscription(subscription) end + def test_process_subscription_handles_braintree_string_amounts + subscription = create_braintree_subscription( + customer: @braintree_customer, + price: "4900", + interval: "month", + quantity: 2 + ) + + assert_equal 9800, Profitable::MrrCalculator.process_subscription(subscription) + end + def test_process_subscription_routes_to_paddle_billing_processor subscription = create_paddle_billing_subscription( customer: @paddle_billing_customer, @@ -179,6 +256,17 @@ def test_process_subscription_routes_to_paddle_billing_processor assert_equal 2900, Profitable::MrrCalculator.process_subscription(subscription) end + def test_process_subscription_handles_paddle_billing_string_amounts + subscription = create_paddle_billing_subscription( + customer: @paddle_billing_customer, + amount: "2900", + interval: "month", + quantity: 2 + ) + + assert_equal 5800, Profitable::MrrCalculator.process_subscription(subscription) + end + def test_process_subscription_routes_to_paddle_classic_processor subscription = create_paddle_classic_subscription( customer: @paddle_classic_customer, @@ -189,6 +277,17 @@ def test_process_subscription_routes_to_paddle_classic_processor assert_equal 1900, Profitable::MrrCalculator.process_subscription(subscription) end + def test_process_subscription_handles_paddle_classic_string_amounts + subscription = create_paddle_classic_subscription( + customer: @paddle_classic_customer, + recurring_price: "1900", + interval: "month", + quantity: 3 + ) + + assert_equal 5700, Profitable::MrrCalculator.process_subscription(subscription) + end + def test_process_subscription_uses_base_processor_for_unknown_processor unknown_customer = create_customer(processor: "unknown_processor") subscription = Pay::Subscription.create!( diff --git a/test/profitable_test.rb b/test/profitable_test.rb index 5a79262..f526e48 100644 --- a/test/profitable_test.rb +++ b/test/profitable_test.rb @@ -34,6 +34,46 @@ def test_mrr_calculates_correctly assert_equal 9900, Profitable.mrr.to_i end + def test_mrr_excludes_subscriptions_still_on_trial_even_if_status_is_active + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + subscription.update!(trial_ends_at: 5.days.from_now) + + assert_equal 0, Profitable.mrr.to_i + end + + def test_mrr_includes_past_due_subscriptions + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "past_due" + ) + + assert_equal 9900, Profitable.mrr.to_i + end + + def test_mrr_excludes_incomplete_and_unpaid_subscriptions + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "incomplete" + ) + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "unpaid" + ) + + assert_equal 0, Profitable.mrr.to_i + end + def test_mrr_to_readable_formats_as_currency create_stripe_subscription_v10( customer: @customer, @@ -125,6 +165,34 @@ def test_churn_calculates_percentage assert churn <= 100, "Churn should be <= 100%" end + def test_churn_excludes_trial_only_subscribers_from_starting_base + active_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + active_subscription.update!(created_at: 60.days.ago) + + churned_subscription = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + churned_subscription.update!(created_at: 60.days.ago, ends_at: 10.days.ago) + + converted_trial = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 9900, + interval: "month", + status: "active" + ) + converted_trial.update!(created_at: 40.days.ago, trial_ends_at: 10.days.ago) + + assert_in_delta 50.0, Profitable.churn(in_the_last: 30.days).to_f, 0.01 + end + # ============================================================================ # ALL TIME REVENUE # ============================================================================ @@ -148,6 +216,81 @@ def test_all_time_revenue_excludes_failed_charges assert_equal 5000, Profitable.all_time_revenue.to_i end + def test_all_time_revenue_subtracts_refunds + create_successful_charge(customer: @customer, amount: 5000, amount_refunded: 1200) + create_successful_charge(customer: @customer, amount: 3000) + + assert_equal 6800, Profitable.all_time_revenue.to_i + end + + def test_all_time_revenue_treats_full_refunds_as_zero_revenue + create_successful_charge(customer: @customer, amount: 5000, amount_refunded: 5000) + + assert_equal 0, Profitable.all_time_revenue.to_i + end + + # ============================================================================ + # TTM REVENUE + # ============================================================================ + + def test_ttm_revenue_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.ttm_revenue + end + + def test_ttm_revenue_only_includes_last_twelve_months_and_subtracts_refunds + old_charge = create_successful_charge(customer: @customer, amount: 10000) + old_charge.update!(created_at: 13.months.ago) + + recent_charge = create_successful_charge(customer: @customer, amount: 5000) + recent_charge.update!(created_at: 11.months.ago) + + refunded_charge = create_successful_charge(customer: @customer, amount: 4000, amount_refunded: 1000) + refunded_charge.update!(created_at: 1.month.ago) + + assert_equal 8000, Profitable.ttm_revenue.to_i + end + + def test_ttm_revenue_honors_twelve_month_boundary + included_charge = create_successful_charge(customer: @customer, amount: 5000) + included_charge.update!(created_at: 12.months.ago + 1.second) + + excluded_charge = create_successful_charge(customer: @customer, amount: 7000) + excluded_charge.update!(created_at: 12.months.ago - 1.second) + + assert_equal 5000, Profitable.ttm_revenue.to_i + end + + # ============================================================================ + # REVENUE RUN RATE + # ============================================================================ + + def test_revenue_run_rate_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.revenue_run_rate + end + + def test_revenue_run_rate_annualizes_recent_revenue + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge(customer: @customer, amount: 3000) + + assert_equal 96000, Profitable.revenue_run_rate(in_the_last: 30.days).to_i + end + + def test_revenue_run_rate_subtracts_refunds + create_successful_charge(customer: @customer, amount: 5000, amount_refunded: 2000) + + assert_equal 36000, Profitable.revenue_run_rate(in_the_last: 30.days).to_i + end + + def test_revenue_run_rate_returns_zero_for_zero_length_period + assert_equal 0, Profitable.revenue_run_rate(in_the_last: 0.seconds).to_i + end + + def test_revenue_run_rate_scales_non_thirty_day_periods + create_successful_charge(customer: @customer, amount: 4000) + + assert_equal 96000, Profitable.revenue_run_rate(in_the_last: 15.days).to_i + end + # ============================================================================ # REVENUE IN PERIOD # ============================================================================ @@ -168,6 +311,13 @@ def test_revenue_in_period_only_includes_charges_in_period assert_equal 8000, Profitable.revenue_in_period(in_the_last: 30.days).to_i end + def test_revenue_in_period_subtracts_refunds + create_successful_charge(customer: @customer, amount: 5000, amount_refunded: 2000) + create_successful_charge(customer: @customer, amount: 3000) + + assert_equal 6000, Profitable.revenue_in_period(in_the_last: 30.days).to_i + end + # ============================================================================ # RECURRING REVENUE IN PERIOD # ============================================================================ @@ -188,6 +338,23 @@ def test_recurring_revenue_in_period_only_includes_subscription_charges assert_equal 9900, Profitable.recurring_revenue_in_period(in_the_last: 30.days).to_i end + def test_recurring_revenue_in_period_subtracts_refunds + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + create_successful_charge( + customer: @customer, + amount: 9900, + amount_refunded: 1900, + subscription: subscription + ) + + assert_equal 8000, Profitable.recurring_revenue_in_period(in_the_last: 30.days).to_i + end + # ============================================================================ # RECURRING REVENUE PERCENTAGE # ============================================================================ @@ -291,17 +458,88 @@ def test_estimated_valuation_clamps_multiplier_range assert_equal expected, high_valuation end + def test_estimated_valuation_matches_estimated_arr_valuation + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + assert_equal Profitable.estimated_arr_valuation(5).to_i, Profitable.estimated_valuation(5).to_i + end + + def test_estimated_arr_valuation_clamps_low_multiplier + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + assert_equal 12000, Profitable.estimated_arr_valuation(0).to_i + end + + def test_estimated_ttm_revenue_valuation_uses_ttm_revenue + charge = create_successful_charge(customer: @customer, amount: 8000) + charge.update!(created_at: 2.months.ago) + + assert_equal 32000, Profitable.estimated_ttm_revenue_valuation(4).to_i + end + + def test_estimated_ttm_revenue_valuation_accepts_at_keyword + charge = create_successful_charge(customer: @customer, amount: 8000) + charge.update!(created_at: 2.months.ago) + + assert_equal 32000, Profitable.estimated_ttm_revenue_valuation(at: 4).to_i + end + + def test_estimated_revenue_run_rate_valuation_uses_recent_revenue_run_rate + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge(customer: @customer, amount: 3000) + + assert_equal 192000, Profitable.estimated_revenue_run_rate_valuation(2, in_the_last: 30.days).to_i + end + + def test_estimated_revenue_run_rate_valuation_accepts_multiple_keyword + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge(customer: @customer, amount: 3000) + + assert_equal 192000, Profitable.estimated_revenue_run_rate_valuation(multiple: 2, in_the_last: 30.days).to_i + end + # ============================================================================ # SUBSCRIBER COUNTS # ============================================================================ - def test_total_customers_counts_customers_with_charges + def test_total_customers_counts_customers_with_charges_and_billable_subscriptions create_successful_charge(customer: @customer, amount: 5000) customer2 = create_customer(processor: "stripe") create_successful_charge(customer: customer2, amount: 3000) - assert_equal 2, Profitable.total_customers.to_i + customer3 = create_customer(processor: "stripe") + subscription = create_stripe_subscription_v10(customer: customer3, unit_amount: 4900, interval: "month") + subscription.update!(created_at: 45.days.ago, trial_ends_at: 15.days.ago) + + assert_equal 3, Profitable.total_customers.to_i + end + + def test_total_customers_excludes_trial_only_and_incomplete_subscriptions_without_charges + trial_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "trialing" + ) + trial_subscription.update!(trial_ends_at: 7.days.from_now) + + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "incomplete" + ) + + assert_equal 0, Profitable.total_customers.to_i end def test_total_subscribers_counts_customers_with_subscriptions @@ -313,6 +551,33 @@ def test_total_subscribers_counts_customers_with_subscriptions assert_equal 2, Profitable.total_subscribers.to_i end + def test_total_subscribers_excludes_trial_only_subscriptions + create_stripe_subscription_v10(customer: @customer, unit_amount: 9900, interval: "month") + + trial_customer = create_customer(processor: "stripe") + trial_subscription = create_stripe_subscription_v10( + customer: trial_customer, + unit_amount: 4900, + interval: "month", + status: "trialing" + ) + trial_subscription.update!(trial_ends_at: 7.days.from_now) + + assert_equal 1, Profitable.total_subscribers.to_i + end + + def test_total_subscribers_includes_converted_trials + converted_trial = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + converted_trial.update!(created_at: 45.days.ago, trial_ends_at: 15.days.ago) + + assert_equal 1, Profitable.total_subscribers.to_i + end + def test_active_subscribers_counts_only_active_subscriptions # Active subscription create_stripe_subscription_v10( @@ -334,21 +599,73 @@ def test_active_subscribers_counts_only_active_subscriptions assert_equal 1, Profitable.active_subscribers.to_i end + def test_active_subscribers_includes_past_due_and_active_subscriptions_with_future_end_dates + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "past_due" + ) + + scheduled_end = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "active" + ) + scheduled_end.update!(ends_at: 5.days.from_now) + + assert_equal 2, Profitable.active_subscribers.to_i + end + # ============================================================================ # NEW CUSTOMERS AND SUBSCRIBERS # ============================================================================ def test_new_customers_in_period - # Customer created 60 days ago (outside period) + # Existing signup who first paid outside the period old_customer = create_customer(processor: "stripe") old_customer.update!(created_at: 60.days.ago) - create_successful_charge(customer: old_customer, amount: 5000) + old_charge = create_successful_charge(customer: old_customer, amount: 5000) + old_charge.update!(created_at: 60.days.ago) + + # Existing signup who first became a customer inside the period + existing_signup = create_customer(processor: "stripe") + existing_signup.update!(created_at: 60.days.ago) + create_successful_charge(customer: existing_signup, amount: 3000) - # New customer + # Truly new signup who also monetized inside the period new_customer = create_customer(processor: "stripe") - create_successful_charge(customer: new_customer, amount: 3000) + create_successful_charge(customer: new_customer, amount: 4000) + + assert_equal 2, Profitable.new_customers(in_the_last: 30.days).to_i + end - assert_equal 1, Profitable.new_customers(in_the_last: 30.days).to_i + def test_new_customers_uses_trial_conversion_date_for_subscription_only_customers + trial_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + trial_subscription.update!(created_at: 25.days.ago, trial_ends_at: 5.days.ago) + + assert_equal 1, Profitable.new_customers(in_the_last: 10.days).to_i + assert_equal 0, Profitable.new_customers(in_the_last: 3.days).to_i + end + + def test_new_customers_uses_earliest_monetization_event + create_successful_charge(customer: @customer, amount: 5000, created_at: 60.days.ago) + + later_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + later_subscription.update!(created_at: 10.days.ago) + + assert_equal 0, Profitable.new_customers(in_the_last: 30.days).to_i end def test_new_subscribers_counts_new_subscriptions_in_period @@ -371,6 +688,36 @@ def test_new_subscribers_counts_new_subscriptions_in_period assert_equal 1, Profitable.new_subscribers(in_the_last: 30.days).to_i end + def test_new_subscribers_uses_trial_conversion_date + trial_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + trial_subscription.update!(created_at: 25.days.ago, trial_ends_at: 5.days.ago) + + assert_equal 1, Profitable.new_subscribers(in_the_last: 10.days).to_i + assert_equal 0, Profitable.new_subscribers(in_the_last: 3.days).to_i + end + + def test_new_subscribers_excludes_incomplete_and_unpaid_subscriptions + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "incomplete" + ) + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "unpaid" + ) + + assert_equal 0, Profitable.new_subscribers(in_the_last: 30.days).to_i + end + # ============================================================================ # CHURNED CUSTOMERS # ============================================================================ @@ -434,6 +781,48 @@ def test_new_mrr_excludes_trialing_subscriptions assert_equal 0, Profitable.new_mrr(in_the_last: 30.days).to_i end + def test_new_mrr_counts_subscriptions_that_churned_later_in_period + churned_sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + churned_sub.update!(created_at: 20.days.ago, ends_at: 5.days.ago) + + assert_equal 9900, Profitable.new_mrr(in_the_last: 30.days).to_i + end + + def test_new_mrr_uses_trial_conversion_date + trial_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + trial_subscription.update!(created_at: 25.days.ago, trial_ends_at: 5.days.ago) + + assert_equal 9900, Profitable.new_mrr(in_the_last: 10.days).to_i + assert_equal 0, Profitable.new_mrr(in_the_last: 3.days).to_i + end + + def test_new_mrr_excludes_incomplete_and_unpaid_subscriptions + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "incomplete" + ) + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "unpaid" + ) + + assert_equal 0, Profitable.new_mrr(in_the_last: 30.days).to_i + end + # ============================================================================ # CHURNED MRR # ============================================================================ @@ -605,6 +994,25 @@ def test_mrr_growth_rate_returns_zero_when_no_starting_mrr assert_equal 0, Profitable.mrr_growth_rate(in_the_last: 30.days).to_f end + def test_mrr_growth_rate_uses_billable_historical_snapshots + surviving_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + surviving_subscription.update!(created_at: 60.days.ago) + + churned_subscription = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 5000, + interval: "month", + status: "canceled" + ) + churned_subscription.update!(created_at: 60.days.ago, ends_at: 15.days.ago) + + assert_in_delta(-33.33, Profitable.mrr_growth_rate(in_the_last: 30.days).to_f, 0.01) + end + # ============================================================================ # TIME TO NEXT MRR MILESTONE # ============================================================================ @@ -679,6 +1087,25 @@ def test_monthly_summary_captures_new_subscribers assert_equal 9900, current_month[:new_mrr] end + def test_monthly_summary_uses_trial_conversion_month_for_new_subscribers_and_new_mrr + trial_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + trial_subscription.update!(created_at: 40.days.ago, trial_ends_at: 5.days.ago) + + result = Profitable.monthly_summary(months: 2) + current_month = result.last + previous_month = result.first + + assert_equal 1, current_month[:new_subscribers] + assert_equal 9900, current_month[:new_mrr] + assert_equal 0, previous_month[:new_subscribers] + assert_equal 0, previous_month[:new_mrr] + end + def test_monthly_summary_captures_churned_subscribers churned_sub = create_stripe_subscription_v10( customer: @customer, @@ -765,6 +1192,23 @@ def test_daily_summary_captures_new_subscriber_today assert_equal 1, today[:new_subscribers] end + def test_daily_summary_uses_trial_conversion_day_for_new_subscribers + trial_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + trial_subscription.update!(created_at: 10.days.ago, trial_ends_at: Time.current) + + result = Profitable.daily_summary(days: 2) + today = result.last + yesterday = result.first + + assert_equal 1, today[:new_subscribers] + assert_equal 0, yesterday[:new_subscribers] + end + def test_daily_summary_captures_churned_subscriber churned_sub = create_stripe_subscription_v10( customer: @customer, @@ -887,6 +1331,24 @@ def test_period_data_new_mrr_and_churned_mrr assert_equal 5000, data[:mrr_growth].to_i end + def test_period_data_revenue_is_net_of_refunds + create_successful_charge(customer: @customer, amount: 5000, amount_refunded: 2000) + + data = Profitable.period_data(in_the_last: 30.days) + + assert_equal 3000, data[:revenue].to_i + end + + def test_period_data_new_customers_uses_first_monetization_date + existing_signup = create_customer(processor: "stripe") + existing_signup.update!(created_at: 60.days.ago) + create_successful_charge(customer: existing_signup, amount: 4000) + + data = Profitable.period_data(in_the_last: 30.days) + + assert_equal 1, data[:new_customers].to_i + end + # ============================================================================ # REGRESSION: paid_charges backwards compatibility # ============================================================================ diff --git a/test/test_helper.rb b/test/test_helper.rb index 9cba1c1..fe4d551 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -134,8 +134,9 @@ class Charge < ActiveRecord::Base # mirror the Profitable module here. module Profitable # Subscription status constants (at module level so MrrCalculator can reference them) - EXCLUDED_STATUSES = ['trialing', 'paused'].freeze CHURNED_STATUSES = ['canceled', 'ended'].freeze + NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze + CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES = (CHURNED_STATUSES + ['trialing', 'paused'] + NEVER_BILLABLE_SUBSCRIPTION_STATUSES).freeze class << self include ActionView::Helpers::NumberHelper @@ -160,6 +161,10 @@ def all_time_revenue NumericResult.new(calculate_all_time_revenue) end + def ttm_revenue + revenue_in_period(in_the_last: 12.months) + end + def revenue_in_period(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_revenue_in_period(in_the_last)) end @@ -172,9 +177,27 @@ def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) end + def revenue_run_rate(in_the_last: 30.days) + NumericResult.new(calculate_revenue_run_rate(in_the_last)) + end + def estimated_valuation(multiplier = nil, at: nil, multiple: nil) + estimated_arr_valuation(multiplier, at:, multiple:) + end + + def estimated_arr_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(arr.to_i, actual_multiplier)) + end + + def estimated_ttm_revenue_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier)) + end + + def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days) actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation(actual_multiplier)) + NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier)) end def total_customers @@ -269,6 +292,47 @@ def subscriptions_with_processor(scope = Pay::Subscription.all) .joins(:customer) end + def subscription_became_billable_at_sql + 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)' + end + + def subscription_is_billable_by(date, scope = Pay::Subscription.all) + scope.where( + "(pay_subscriptions.status != ? OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", + 'trialing', + date + ) + end + + def ever_billable_subscription_scope(scope = Pay::Subscription.all) + subscription_is_billable_by(Time.current, scope) + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where("#{subscription_became_billable_at_sql} <= ?", Time.current) + end + + def billable_subscription_scope_at(date, scope = Pay::Subscription.all) + subscription_is_billable_by(date, scope) + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where("#{subscription_became_billable_at_sql} <= ?", date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) + .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) + end + + def current_billable_subscription_scope(scope = Pay::Subscription.all) + billable_subscription_scope_at(Time.current, scope) + .where.not(status: CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES) + end + + def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) + subscription_is_billable_by(period_end, scope) + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) + end + + def subscription_became_billable_at(subscription) + subscription.trial_ends_at || subscription.created_at + end + def paid_charges # Pay gem v10+ stores charge data in `object` column, older versions used `data` # We check both columns for backwards compatibility using database-agnostic JSON extraction @@ -294,8 +358,16 @@ def paid_charges SQL end + def net_revenue(scope) + scope.sum(net_charge_amount_sql) + end + + def net_charge_amount_sql + "pay_charges.amount - COALESCE(pay_charges.amount_refunded, 0)" + end + def calculate_all_time_revenue - paid_charges.sum(:amount) + net_revenue(paid_charges) end def calculate_arr @@ -303,8 +375,12 @@ def calculate_arr end def calculate_estimated_valuation(multiplier = 3) + calculate_estimated_valuation_from(calculate_arr, multiplier) + end + + def calculate_estimated_valuation_from(base_amount, multiplier = 3) multiplier = parse_multiplier(multiplier) - (calculate_arr * multiplier).round + (base_amount * multiplier).round end def parse_multiplier(input) @@ -339,14 +415,22 @@ def calculate_new_mrr(period = DEFAULT_PERIOD) end def calculate_revenue_in_period(period) - paid_charges.where(created_at: period.ago..Time.current).sum(:amount) + net_revenue(paid_charges.where(created_at: period.ago..Time.current)) + end + + def calculate_revenue_run_rate(period) + return 0 if period.to_i <= 0 + + monthly_revenue = calculate_revenue_in_period(period).to_f * (30.days.to_f / period.to_f) + (monthly_revenue * 12).round end def calculate_recurring_revenue_in_period(period) - paid_charges - .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') - .where(created_at: period.ago..Time.current) - .sum(:amount) + net_revenue( + paid_charges + .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') + .where(created_at: period.ago..Time.current) + ) end def calculate_recurring_revenue_percentage(period) @@ -359,32 +443,39 @@ def calculate_recurring_revenue_percentage(period) end def calculate_total_customers - Pay::Customer.joins(:charges) - .merge(paid_charges) - .distinct - .count + actual_customers.count end def calculate_total_subscribers - Pay::Customer.joins(:subscriptions).distinct.count + ever_billable_subscription_scope.distinct.count(:customer_id) end def calculate_active_subscribers - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { status: 'active' }) - .distinct - .count + current_billable_subscription_scope.distinct.count(:customer_id) end def actual_customers - Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id") - .joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id") - .where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0") - .distinct + customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id)) + customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id)) + + customers_with_paid_charges.or(customers_with_billable_subscriptions).distinct end def calculate_new_customers(period) - actual_customers.where(created_at: period.ago..Time.current).count + period_start = period.ago + period_end = Time.current + + first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at) + first_subscription_dates = ever_billable_subscription_scope + .group(:customer_id) + .minimum(Arel.sql(subscription_became_billable_at_sql)) + + customer_ids = first_charge_dates.keys | first_subscription_dates.keys + + customer_ids.count do |customer_id| + first_customer_date = [first_charge_dates[customer_id], first_subscription_dates[customer_id]].compact.min + first_customer_date && first_customer_date >= period_start && first_customer_date <= period_end + end end def calculate_new_subscribers(period) @@ -429,16 +520,12 @@ def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) def calculate_mrr_at(date) # Find subscriptions that were active AT the given date: - # - Created before or on that date + # - Started billing before or on that date # - Not ended before that date (ends_at is nil OR ends_at > date) # - Not paused at that date - # - Not in trialing status (trials don't count as MRR) + # - Not still in a free trial at that date subscriptions_with_processor( - Pay::Subscription - .where('pay_subscriptions.created_at <= ?', date) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) - .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) - .where.not(status: EXCLUDED_STATUSES) + billable_subscription_scope_at(date) ).sum do |subscription| MrrCalculator.process_subscription(subscription) end @@ -448,17 +535,14 @@ def calculate_period_data(period) period_start = period.ago period_end = Time.current - new_customers_count = actual_customers.where(created_at: period_start..period_end).count + new_customers_count = calculate_new_customers(period) churned_count = calculate_churned_subscribers_in_period(period_start, period_end) new_mrr_val = calculate_new_mrr_in_period(period_start, period_end) churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end) - revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount) + revenue_val = net_revenue(paid_charges.where(created_at: period_start..period_end)) # Churn rate (reuses churned_count) - total_at_start = Pay::Subscription - .where('pay_subscriptions.created_at < ?', period_start) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start) - .where.not(status: EXCLUDED_STATUSES) + total_at_start = billable_subscription_scope_at(period_start) .distinct .count('customer_id') churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 @@ -481,9 +565,8 @@ def calculate_monthly_summary(months_count) # Bulk load all data for the full range new_sub_records = Pay::Subscription - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) + .merge(billable_subscription_events_in_period(overall_start, overall_end)) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) churned_sub_records = Pay::Subscription .where(status: CHURNED_STATUSES) @@ -491,9 +574,7 @@ def calculate_monthly_summary(months_count) .pluck(:customer_id, :ends_at) new_mrr_subs = subscriptions_with_processor( - Pay::Subscription - .where(status: 'active') - .where(created_at: overall_start..overall_end) + billable_subscription_events_in_period(overall_start, overall_end) ).to_a churned_mrr_subs = subscriptions_with_processor( @@ -502,11 +583,9 @@ def calculate_monthly_summary(months_count) .where(ends_at: overall_start..overall_end) ).to_a - churn_base_records = Pay::Subscription - .where('pay_subscriptions.created_at < ?', overall_end) + churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription) .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at, :ends_at) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at) # Group by month in Ruby summary = [] @@ -523,7 +602,7 @@ def calculate_monthly_summary(months_count) .map(&:first).uniq.count new_mrr_amount = new_mrr_subs - .select { |s| s.created_at >= month_start && s.created_at <= month_end } + .select { |s| subscription_became_billable_at(s) >= month_start && subscription_became_billable_at(s) <= month_end } .sum { |s| MrrCalculator.process_subscription(s) } churned_mrr_amount = churned_mrr_subs @@ -531,7 +610,7 @@ def calculate_monthly_summary(months_count) .sum { |s| MrrCalculator.process_subscription(s) } total_at_start = churn_base_records - .select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) } + .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) } .map(&:first).uniq.count churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 @@ -558,9 +637,8 @@ def calculate_daily_summary(days_count) overall_end = Time.current.end_of_day new_sub_records = Pay::Subscription - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) + .merge(billable_subscription_events_in_period(overall_start, overall_end)) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) churned_sub_records = Pay::Subscription .where(status: CHURNED_STATUSES) @@ -592,11 +670,9 @@ def calculate_daily_summary(days_count) # Consolidated methods that work with any date range def calculate_new_subscribers_in_period(period_start, period_end) - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { created_at: period_start..period_end }) - .where.not(pay_subscriptions: { status: EXCLUDED_STATUSES }) - .distinct - .count + billable_subscription_events_in_period(period_start, period_end) + .distinct + .count(:customer_id) end def calculate_churned_subscribers_in_period(period_start, period_end) @@ -609,9 +685,7 @@ def calculate_churned_subscribers_in_period(period_start, period_end) def calculate_new_mrr_in_period(period_start, period_end) subscriptions_with_processor( - Pay::Subscription - .where(status: 'active') - .where(created_at: period_start..period_end) + billable_subscription_events_in_period(period_start, period_end) ).sum do |subscription| MrrCalculator.process_subscription(subscription) end @@ -629,10 +703,7 @@ def calculate_churned_mrr_in_period(period_start, period_end) def calculate_churn_rate_for_period(period_start, period_end) # Count subscribers who were active AT the start of the period - total_subscribers_start = Pay::Subscription - .where('pay_subscriptions.created_at < ?', period_start) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start) - .where.not(status: EXCLUDED_STATUSES) + total_subscribers_start = billable_subscription_scope_at(period_start) .distinct .count('customer_id') @@ -807,12 +878,13 @@ def create_customer(processor:) end # Creates a successful charge - def create_successful_charge(customer:, amount:, subscription: nil, created_at: Time.current) + def create_successful_charge(customer:, amount:, subscription: nil, created_at: Time.current, amount_refunded: 0) Pay::Charge.create!( customer: customer, subscription: subscription, processor_id: "ch_#{SecureRandom.hex(8)}", amount: amount, + amount_refunded: amount_refunded, currency: "usd", created_at: created_at, object: { @@ -823,12 +895,13 @@ def create_successful_charge(customer:, amount:, subscription: nil, created_at: end # Creates a charge with legacy data column - def create_successful_charge_legacy(customer:, amount:, subscription: nil, created_at: Time.current) + def create_successful_charge_legacy(customer:, amount:, subscription: nil, created_at: Time.current, amount_refunded: 0) Pay::Charge.create!( customer: customer, subscription: subscription, processor_id: "ch_#{SecureRandom.hex(8)}", amount: amount, + amount_refunded: amount_refunded, currency: "usd", created_at: created_at, object: nil, From b60e0b5e58a97bff935b757301f35791c428ce7b Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:13:21 +0000 Subject: [PATCH 2/6] Harden Pay lifecycle compatibility --- CHANGELOG.md | 3 + README.md | 4 + lib/profitable.rb | 28 ++-- lib/profitable/mrr_calculator.rb | 15 ++- lib/profitable/processors/stripe_processor.rb | 1 + test/mrr_calculator_test.rb | 60 +++++++++ test/profitable_test.rb | 122 ++++++++++++++++++ test/test_helper.rb | 36 ++++-- 8 files changed, 243 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 645303c..974d019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - Make subscriber and MRR metrics distinguish between current billable subscriptions and historical period events - Count `new_customers` from first monetization date rather than signup date - Count `new_subscribers` / `new_mrr` from when a subscription becomes billable, not when a free trial starts +- Handle additional Pay status variants like `on_trial`, `cancelled`, and `deleted` +- Keep grace-period subscriptions billable until `ends_at` +- Exclude metered Stripe items from fixed run-rate MRR calculations - Surface TTM revenue in the built-in dashboard ## [0.4.0] - 2026-02-10 diff --git a/README.md b/README.md index 869159f..5407b4a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Then run `bundle install`. Provided you have a valid [`pay`](https://github.com/pay-rails/pay) installation (`Pay::Customer`, `Pay::Subscription`, `Pay::Charge`, etc.) everything is already set up and you can just start using [`Profitable` methods](#main-profitable-methods) right away. +Current MRR processor coverage is verified for `stripe`, `braintree`, `paddle_billing`, and `paddle_classic`. + +For Stripe, metered subscription items are intentionally excluded from fixed run-rate metrics like `mrr`, `arr`, `new_mrr`, and `churned_mrr`. + ## Mount the `/profitable` dashboard `profitable` also provides a simple dashboard to see your main business metrics. diff --git a/lib/profitable.rb b/lib/profitable.rb index 9e768b6..0f8ddc1 100644 --- a/lib/profitable.rb +++ b/lib/profitable.rb @@ -14,9 +14,9 @@ module Profitable # Subscription status constants (at module level so MrrCalculator can reference them) - CHURNED_STATUSES = ['canceled', 'ended'].freeze + TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze + CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze - CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES = (CHURNED_STATUSES + ['trialing', 'paused'] + NEVER_BILLABLE_SUBSCRIPTION_STATUSES).freeze class << self include ActionView::Helpers::NumberHelper @@ -198,22 +198,30 @@ def subscription_became_billable_at_sql end def subscription_is_billable_by(date, scope = Pay::Subscription.all) - scope.where( - "(pay_subscriptions.status != ? OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", - 'trialing', - date - ) + scope + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where( + "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", + TRIAL_SUBSCRIPTION_STATUSES, + date + ) + .where( + "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)", + CHURNED_STATUSES + ) + .where( + "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)", + 'paused' + ) end def ever_billable_subscription_scope(scope = Pay::Subscription.all) subscription_is_billable_by(Time.current, scope) - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where("#{subscription_became_billable_at_sql} <= ?", Time.current) end def billable_subscription_scope_at(date, scope = Pay::Subscription.all) subscription_is_billable_by(date, scope) - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where("#{subscription_became_billable_at_sql} <= ?", date) .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) @@ -221,12 +229,10 @@ def billable_subscription_scope_at(date, scope = Pay::Subscription.all) def current_billable_subscription_scope(scope = Pay::Subscription.all) billable_subscription_scope_at(Time.current, scope) - .where.not(status: CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES) end def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) subscription_is_billable_by(period_end, scope) - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) end diff --git a/lib/profitable/mrr_calculator.rb b/lib/profitable/mrr_calculator.rb index 04f670f..2e284a6 100644 --- a/lib/profitable/mrr_calculator.rb +++ b/lib/profitable/mrr_calculator.rb @@ -9,7 +9,20 @@ class MrrCalculator def self.calculate total_mrr = 0 subscriptions = Pay::Subscription - .where.not(status: Profitable::CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES) + .where.not(status: Profitable::NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where( + "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", + Profitable::TRIAL_SUBSCRIPTION_STATUSES, + Time.current + ) + .where( + "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)", + Profitable::CHURNED_STATUSES + ) + .where( + "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)", + 'paused' + ) .where('COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at) <= ?', Time.current) .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', Time.current) .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', Time.current) diff --git a/lib/profitable/processors/stripe_processor.rb b/lib/profitable/processors/stripe_processor.rb index 443eae1..67fb266 100644 --- a/lib/profitable/processors/stripe_processor.rb +++ b/lib/profitable/processors/stripe_processor.rb @@ -17,6 +17,7 @@ def calculate_mrr subscription_items.each do |item| price_data = item['price'] || item next if price_data.nil? + next if price_data.dig('recurring', 'usage_type') == 'metered' amount = price_data['unit_amount'] next if amount.nil? diff --git a/test/mrr_calculator_test.rb b/test/mrr_calculator_test.rb index 37a0494..b1ca51a 100644 --- a/test/mrr_calculator_test.rb +++ b/test/mrr_calculator_test.rb @@ -84,6 +84,18 @@ def test_calculate_excludes_trialing_subscriptions assert_equal 0, Profitable::MrrCalculator.calculate end + def test_calculate_excludes_on_trial_subscriptions + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "on_trial" + ) + subscription.update!(trial_ends_at: 5.days.from_now) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + def test_calculate_excludes_active_subscriptions_still_on_trial subscription = create_stripe_subscription_v10( customer: @stripe_customer, @@ -163,6 +175,30 @@ def test_calculate_includes_past_due_subscriptions assert_equal 9900, Profitable::MrrCalculator.calculate end + def test_calculate_includes_canceled_subscription_still_in_grace_period + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + subscription.update!(ends_at: 5.days.from_now) + + assert_equal 9900, Profitable::MrrCalculator.calculate + end + + def test_calculate_includes_cancelled_subscription_still_in_grace_period + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "cancelled" + ) + subscription.update!(ends_at: 5.days.from_now) + + assert_equal 9900, Profitable::MrrCalculator.calculate + end + def test_calculate_includes_active_subscriptions_until_future_pause_or_end_date pausing_subscription = create_stripe_subscription_v10( customer: @stripe_customer, @@ -225,6 +261,30 @@ def test_process_subscription_routes_to_stripe_processor assert_equal 9900, Profitable::MrrCalculator.process_subscription(subscription) end + def test_process_subscription_ignores_metered_stripe_items + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 5000, + interval: "month", + usage_type: "metered" + ) + + assert_equal 0, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_ignores_metered_items_but_keeps_licensed_items + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 5000, + interval: "month", + additional_items: [ + { unit_amount: 2000, interval: "month", usage_type: "metered" } + ] + ) + + assert_equal 5000, Profitable::MrrCalculator.process_subscription(subscription) + end + def test_process_subscription_routes_to_braintree_processor subscription = create_braintree_subscription( customer: @braintree_customer, diff --git a/test/profitable_test.rb b/test/profitable_test.rb index f526e48..f821d6a 100644 --- a/test/profitable_test.rb +++ b/test/profitable_test.rb @@ -46,6 +46,18 @@ def test_mrr_excludes_subscriptions_still_on_trial_even_if_status_is_active assert_equal 0, Profitable.mrr.to_i end + def test_mrr_excludes_on_trial_status_until_trial_ends + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "on_trial" + ) + subscription.update!(trial_ends_at: 5.days.from_now) + + assert_equal 0, Profitable.mrr.to_i + end + def test_mrr_includes_past_due_subscriptions create_stripe_subscription_v10( customer: @customer, @@ -57,6 +69,18 @@ def test_mrr_includes_past_due_subscriptions assert_equal 9900, Profitable.mrr.to_i end + def test_mrr_includes_cancel_at_period_end_subscriptions_still_in_grace_period + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + subscription.update!(ends_at: 5.days.from_now) + + assert_equal 9900, Profitable.mrr.to_i + end + def test_mrr_excludes_incomplete_and_unpaid_subscriptions create_stripe_subscription_v10( customer: @customer, @@ -578,6 +602,26 @@ def test_total_subscribers_includes_converted_trials assert_equal 1, Profitable.total_subscribers.to_i end + def test_total_subscribers_includes_cancelled_and_deleted_subscriptions_that_became_billable + cancelled_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "cancelled" + ) + cancelled_subscription.update!(created_at: 45.days.ago, trial_ends_at: 30.days.ago, ends_at: 5.days.ago) + + deleted_subscription = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 4900, + interval: "month", + status: "deleted" + ) + deleted_subscription.update!(created_at: 45.days.ago, ends_at: 5.days.ago) + + assert_equal 2, Profitable.total_subscribers.to_i + end + def test_active_subscribers_counts_only_active_subscriptions # Active subscription create_stripe_subscription_v10( @@ -618,6 +662,18 @@ def test_active_subscribers_includes_past_due_and_active_subscriptions_with_futu assert_equal 2, Profitable.active_subscribers.to_i end + def test_active_subscribers_includes_cancelled_subscriptions_still_in_grace_period + cancelled_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "cancelled" + ) + cancelled_subscription.update!(ends_at: 5.days.from_now) + + assert_equal 1, Profitable.active_subscribers.to_i + end + # ============================================================================ # NEW CUSTOMERS AND SUBSCRIBERS # ============================================================================ @@ -718,6 +774,18 @@ def test_new_subscribers_excludes_incomplete_and_unpaid_subscriptions assert_equal 0, Profitable.new_subscribers(in_the_last: 30.days).to_i end + def test_new_subscribers_excludes_on_trial_subscriptions_until_trial_ends + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "on_trial" + ) + subscription.update!(trial_ends_at: 5.days.from_now) + + assert_equal 0, Profitable.new_subscribers(in_the_last: 30.days).to_i + end + # ============================================================================ # CHURNED CUSTOMERS # ============================================================================ @@ -744,6 +812,28 @@ def test_churned_customers_counts_ended_subscriptions assert_equal 1, Profitable.churned_customers(in_the_last: 30.days).to_i end + def test_churned_customers_counts_cancelled_and_deleted_status_aliases + cancelled_customer = create_customer(processor: "stripe") + cancelled_subscription = create_stripe_subscription_v10( + customer: cancelled_customer, + unit_amount: 4900, + interval: "month", + status: "cancelled" + ) + cancelled_subscription.update!(created_at: 45.days.ago, ends_at: 10.days.ago) + + deleted_customer = create_customer(processor: "stripe") + deleted_subscription = create_stripe_subscription_v10( + customer: deleted_customer, + unit_amount: 3900, + interval: "month", + status: "deleted" + ) + deleted_subscription.update!(created_at: 45.days.ago, ends_at: 8.days.ago) + + assert_equal 2, Profitable.churned_customers(in_the_last: 30.days).to_i + end + # ============================================================================ # NEW MRR # ============================================================================ @@ -823,6 +913,18 @@ def test_new_mrr_excludes_incomplete_and_unpaid_subscriptions assert_equal 0, Profitable.new_mrr(in_the_last: 30.days).to_i end + def test_new_mrr_excludes_on_trial_subscriptions_until_trial_ends + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "on_trial" + ) + subscription.update!(trial_ends_at: 5.days.from_now) + + assert_equal 0, Profitable.new_mrr(in_the_last: 30.days).to_i + end + # ============================================================================ # CHURNED MRR # ============================================================================ @@ -844,6 +946,26 @@ def test_churned_mrr_calculates_mrr_lost assert_equal 9900, Profitable.churned_mrr(in_the_last: 30.days).to_i end + def test_churned_mrr_counts_cancelled_and_deleted_status_aliases + cancelled_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 4900, + interval: "month", + status: "cancelled" + ) + cancelled_subscription.update!(created_at: 45.days.ago, ends_at: 10.days.ago) + + deleted_subscription = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 3900, + interval: "month", + status: "deleted" + ) + deleted_subscription.update!(created_at: 45.days.ago, ends_at: 8.days.ago) + + assert_equal 8800, Profitable.churned_mrr(in_the_last: 30.days).to_i + end + # ============================================================================ # AVERAGE REVENUE PER CUSTOMER # ============================================================================ diff --git a/test/test_helper.rb b/test/test_helper.rb index fe4d551..10da06b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -134,9 +134,9 @@ class Charge < ActiveRecord::Base # mirror the Profitable module here. module Profitable # Subscription status constants (at module level so MrrCalculator can reference them) - CHURNED_STATUSES = ['canceled', 'ended'].freeze + TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze + CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze - CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES = (CHURNED_STATUSES + ['trialing', 'paused'] + NEVER_BILLABLE_SUBSCRIPTION_STATUSES).freeze class << self include ActionView::Helpers::NumberHelper @@ -297,22 +297,30 @@ def subscription_became_billable_at_sql end def subscription_is_billable_by(date, scope = Pay::Subscription.all) - scope.where( - "(pay_subscriptions.status != ? OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", - 'trialing', - date - ) + scope + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where( + "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", + TRIAL_SUBSCRIPTION_STATUSES, + date + ) + .where( + "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)", + CHURNED_STATUSES + ) + .where( + "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)", + 'paused' + ) end def ever_billable_subscription_scope(scope = Pay::Subscription.all) subscription_is_billable_by(Time.current, scope) - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where("#{subscription_became_billable_at_sql} <= ?", Time.current) end def billable_subscription_scope_at(date, scope = Pay::Subscription.all) subscription_is_billable_by(date, scope) - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where("#{subscription_became_billable_at_sql} <= ?", date) .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) @@ -320,12 +328,10 @@ def billable_subscription_scope_at(date, scope = Pay::Subscription.all) def current_billable_subscription_scope(scope = Pay::Subscription.all) billable_subscription_scope_at(Time.current, scope) - .where.not(status: CURRENT_NON_BILLABLE_SUBSCRIPTION_STATUSES) end def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) subscription_is_billable_by(period_end, scope) - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) end @@ -719,7 +725,7 @@ def calculate_churn_rate_for_period(period_start, period_end) # Test helper methods module ProfitableTestHelpers # Creates a Stripe subscription with the v10+ object column structure - def create_stripe_subscription_v10(customer:, unit_amount:, interval: "month", interval_count: 1, quantity: 1, status: "active", additional_items: []) + def create_stripe_subscription_v10(customer:, unit_amount:, interval: "month", interval_count: 1, quantity: 1, status: "active", usage_type: nil, additional_items: []) items_data = [ { "id" => "si_#{SecureRandom.hex(8)}", @@ -729,7 +735,8 @@ def create_stripe_subscription_v10(customer:, unit_amount:, interval: "month", i "unit_amount" => unit_amount, "recurring" => { "interval" => interval, - "interval_count" => interval_count + "interval_count" => interval_count, + "usage_type" => usage_type } } } @@ -744,7 +751,8 @@ def create_stripe_subscription_v10(customer:, unit_amount:, interval: "month", i "unit_amount" => item[:unit_amount], "recurring" => { "interval" => item[:interval] || interval, - "interval_count" => item[:interval_count] || interval_count + "interval_count" => item[:interval_count] || interval_count, + "usage_type" => item[:usage_type] } } } From 0bc6a603f9f766301253ae83bc049b7cedd22e8c Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:34:26 +0000 Subject: [PATCH 3/6] Document metric semantics inline --- lib/profitable.rb | 43 +++++++++++++++++-- lib/profitable/mrr_calculator.rb | 6 +++ .../processors/braintree_processor.rb | 2 + .../processors/paddle_billing_processor.rb | 1 + .../processors/paddle_classic_processor.rb | 1 + lib/profitable/processors/stripe_processor.rb | 3 ++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/lib/profitable.rb b/lib/profitable.rb index 0f8ddc1..fb10bb8 100644 --- a/lib/profitable.rb +++ b/lib/profitable.rb @@ -13,7 +13,9 @@ require "action_view" module Profitable - # Subscription status constants (at module level so MrrCalculator can reference them) + # Pay exposes some processor-specific status variants beyond the core generic list. + # We normalize them into business-meaningful groups so current-state metrics, + # historical event metrics, and churn denominators all behave consistently. TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze @@ -193,10 +195,16 @@ def subscriptions_with_processor(scope = Pay::Subscription.all) .joins(:customer) end + # Business semantics: a subscription becomes "real" for subscriber / new MRR + # reporting when billing starts. For trialless subscriptions that is created_at; + # for trials it is trial_ends_at. def subscription_became_billable_at_sql 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)' end + # We intentionally do not reuse Pay::Subscription.active here. + # Pay's active scope is access-oriented and can include free-trial access, + # while profitable needs billable subscription semantics for metrics. def subscription_is_billable_by(date, scope = Pay::Subscription.all) scope .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) @@ -215,11 +223,15 @@ def subscription_is_billable_by(date, scope = Pay::Subscription.all) ) end + # Any subscription that has ever crossed into a paid/billable state, + # even if it later churned. This is used for "ever" style counts. def ever_billable_subscription_scope(scope = Pay::Subscription.all) subscription_is_billable_by(Time.current, scope) .where("#{subscription_became_billable_at_sql} <= ?", Time.current) end + # Subscriptions that were billable at a historical point in time. + # This powers MRR snapshots, churn denominators, and other period math. def billable_subscription_scope_at(date, scope = Pay::Subscription.all) subscription_is_billable_by(date, scope) .where("#{subscription_became_billable_at_sql} <= ?", date) @@ -227,10 +239,14 @@ def billable_subscription_scope_at(date, scope = Pay::Subscription.all) .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) end + # Current billable subscriptions. A future ends_at or future pause start means + # the subscription is still billable today and should remain in MRR / ARR. def current_billable_subscription_scope(scope = Pay::Subscription.all) billable_subscription_scope_at(Time.current, scope) end + # Historical "new subscriber" / "new MRR" event window. + # The event date is when billing starts, not when the subscription record is created. def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) subscription_is_billable_by(period_end, scope) .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) @@ -371,6 +387,9 @@ def calculate_active_subscribers end def actual_customers + # A "customer" here means a monetized customer, not just an account record. + # We therefore union paid one-off/charge customers with customers whose + # subscriptions have reached a billable state. customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id)) customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id)) @@ -381,6 +400,9 @@ def calculate_new_customers(period) period_start = period.ago period_end = Time.current + # "New customer" is defined by first monetization date. + # We intentionally do not use Pay::Customer.created_at because a user might + # sign up long before they ever pay or convert from trial. first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at) first_subscription_dates = ever_billable_subscription_scope .group(:customer_id) @@ -451,6 +473,8 @@ def calculate_period_data(period) period_start = period.ago period_end = Time.current + # Keep these values delegated to the same underlying helpers used by the + # public methods so the dashboard and direct API calls stay in lockstep. new_customers_count = calculate_new_customers(period) churned_count = calculate_churned_subscribers_in_period(period_start, period_end) new_mrr_val = calculate_new_mrr_in_period(period_start, period_end) @@ -479,7 +503,9 @@ def calculate_monthly_summary(months_count) overall_start = (months_count - 1).months.ago.beginning_of_month overall_end = Time.current.end_of_month - # Bulk load all data for the full range + # Bulk load all data for the full range, then group in Ruby. + # This keeps the dashboard query count low while preserving the same + # billable-date semantics used by the single-metric helpers. new_sub_records = Pay::Subscription .merge(billable_subscription_events_in_period(overall_start, overall_end)) .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) @@ -503,7 +529,8 @@ def calculate_monthly_summary(months_count) .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start) .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at) - # Group by month in Ruby + # Group by month in Ruby using billable-at and ends_at as the event dates, + # rather than raw subscription created_at. summary = [] (months_count - 1).downto(0) do |months_ago| month_start = months_ago.months.ago.beginning_of_month @@ -552,6 +579,8 @@ def calculate_daily_summary(days_count) overall_start = (days_count - 1).days.ago.beginning_of_day overall_end = Time.current.end_of_day + # Daily summary intentionally uses the same "became billable" event date as + # new_subscribers/new_mrr, so trial starts do not appear as paid conversions. new_sub_records = Pay::Subscription .merge(billable_subscription_events_in_period(overall_start, overall_end)) .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) @@ -592,6 +621,7 @@ def calculate_new_subscribers_in_period(period_start, period_end) end def calculate_churned_subscribers_in_period(period_start, period_end) + # Churn happens when access/billing actually ends, which Pay stores on ends_at. Pay::Subscription .where(status: CHURNED_STATUSES) .where(ends_at: period_start..period_end) @@ -600,6 +630,9 @@ def calculate_churned_subscribers_in_period(period_start, period_end) end def calculate_new_mrr_in_period(period_start, period_end) + # New MRR is the full fixed monthly value of subscriptions whose billing + # started in the window. It is not prorated, and it still counts if the + # subscription churns later in the same period. subscriptions_with_processor( billable_subscription_events_in_period(period_start, period_end) ).sum do |subscription| @@ -608,6 +641,7 @@ def calculate_new_mrr_in_period(period_start, period_end) end def calculate_churned_mrr_in_period(period_start, period_end) + # Churned MRR is the full fixed monthly value being lost at churn time. subscriptions_with_processor( Pay::Subscription .where(status: CHURNED_STATUSES) @@ -618,7 +652,8 @@ def calculate_churned_mrr_in_period(period_start, period_end) end def calculate_churn_rate_for_period(period_start, period_end) - # Count subscribers who were active AT the start of the period + # Count subscribers who were billable at the start of the period. + # This keeps free trials and not-yet-paying subscriptions out of the denominator. total_subscribers_start = billable_subscription_scope_at(period_start) .distinct .count('customer_id') diff --git a/lib/profitable/mrr_calculator.rb b/lib/profitable/mrr_calculator.rb index 2e284a6..af161bf 100644 --- a/lib/profitable/mrr_calculator.rb +++ b/lib/profitable/mrr_calculator.rb @@ -8,6 +8,10 @@ module Profitable class MrrCalculator def self.calculate total_mrr = 0 + + # Do not use Pay::Subscription.active here. + # Pay's active scope is designed for entitlement/access checks and can include + # free-trial access. MRR needs subscriptions that are billable right now. subscriptions = Pay::Subscription .where.not(status: Profitable::NEVER_BILLABLE_SUBSCRIPTION_STATUSES) .where( @@ -65,6 +69,8 @@ def self.subscription_data(subscription) end def self.processor_for(processor_name) + # MRR parsing is only implemented for processors with explicit adapters below. + # Unknown processors safely fall back to Base and contribute zero until supported. case processor_name when 'stripe' Processors::StripeProcessor diff --git a/lib/profitable/processors/braintree_processor.rb b/lib/profitable/processors/braintree_processor.rb index ac21bf0..dd60b30 100644 --- a/lib/profitable/processors/braintree_processor.rb +++ b/lib/profitable/processors/braintree_processor.rb @@ -8,6 +8,8 @@ def calculate_mrr amount = data['price'] return 0 if amount.nil? + # Some processor payloads provide amounts as strings, so coerce before + # multiplying by quantity to avoid string repetition bugs. quantity = subscription.quantity || 1 interval = data['billing_period_unit'] interval_count = data['billing_period_frequency'] || 1 diff --git a/lib/profitable/processors/paddle_billing_processor.rb b/lib/profitable/processors/paddle_billing_processor.rb index 10ec19b..6f921cb 100644 --- a/lib/profitable/processors/paddle_billing_processor.rb +++ b/lib/profitable/processors/paddle_billing_processor.rb @@ -18,6 +18,7 @@ def calculate_mrr amount = price_data.dig('unit_price', 'amount') next if amount.nil? + # Paddle can also serialize amounts as strings; coerce before applying quantity. item_quantity = item['quantity'] || 1 interval = price_data.dig('billing_cycle', 'interval') interval_count = price_data.dig('billing_cycle', 'frequency') diff --git a/lib/profitable/processors/paddle_classic_processor.rb b/lib/profitable/processors/paddle_classic_processor.rb index f178950..fe16d4c 100644 --- a/lib/profitable/processors/paddle_classic_processor.rb +++ b/lib/profitable/processors/paddle_classic_processor.rb @@ -8,6 +8,7 @@ def calculate_mrr amount = data['recurring_price'] return 0 if amount.nil? + # Paddle Classic payloads may expose string amounts; coerce before quantity math. quantity = subscription.quantity || 1 interval = data['recurring_interval'] interval_count = 1 # Paddle Classic doesn't have interval_count diff --git a/lib/profitable/processors/stripe_processor.rb b/lib/profitable/processors/stripe_processor.rb index 67fb266..cbb8433 100644 --- a/lib/profitable/processors/stripe_processor.rb +++ b/lib/profitable/processors/stripe_processor.rb @@ -17,6 +17,9 @@ def calculate_mrr subscription_items.each do |item| price_data = item['price'] || item next if price_data.nil? + + # Metered items are usage-based, so they do not have a fixed run-rate that + # belongs in MRR/ARR style metrics. Keep only licensed recurring items here. next if price_data.dig('recurring', 'usage_type') == 'metered' amount = price_data['unit_amount'] From daaeac6f091d859b8b99d3571bc26471eefdf37f Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:51:20 +0000 Subject: [PATCH 4/6] Clarify TTM alias and share metric logic --- CHANGELOG.md | 1 + README.md | 34 +- lib/profitable.rb | 655 +------------------------------------ lib/profitable/metrics.rb | 664 ++++++++++++++++++++++++++++++++++++++ test/profitable_test.rb | 8 + test/test_helper.rb | 601 +--------------------------------- 6 files changed, 706 insertions(+), 1257 deletions(-) create mode 100644 lib/profitable/metrics.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 974d019..78e5c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - Add `Profitable.ttm_revenue` for trailing twelve-month revenue +- Add `Profitable.ttm` as a founder-friendly alias for `ttm_revenue` - Add `Profitable.revenue_run_rate`, `estimated_arr_valuation`, `estimated_ttm_revenue_valuation`, and `estimated_revenue_run_rate_valuation` - Make revenue metrics net of refunds when `amount_refunded` is present - Make subscriber and MRR metrics distinguish between current billable subscriptions and historical period events diff --git a/README.md b/README.md index 5407b4a..03759aa 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,20 @@ Current MRR processor coverage is verified for `stripe`, `braintree`, `paddle_bi For Stripe, metered subscription items are intentionally excluded from fixed run-rate metrics like `mrr`, `arr`, `new_mrr`, and `churned_mrr`. +> [!IMPORTANT] +> `profitable` does **not** yet normalize MRR for every processor that `pay` supports. +> If a subscription comes from an unsupported processor such as `lemon_squeezy`, it will currently contribute `0` to processor-adapter-dependent metrics until an adapter is added. +> +> Verified processor-adapter coverage today: +> - `stripe` +> - `braintree` +> - `paddle_billing` +> - `paddle_classic` +> +> Metrics that depend on processor-specific subscription amount parsing include `mrr`, `arr`, `new_mrr`, `churned_mrr`, `mrr_growth`, `mrr_growth_rate`, `lifetime_value`, `time_to_next_mrr_milestone`, and MRR-derived fields in summaries. +> +> Metrics based primarily on `Pay::Charge` and generic subscription lifecycle fields are much more portable across processors, including `all_time_revenue`, `revenue_in_period`, `ttm_revenue`, `revenue_run_rate`, customer counts, subscriber counts, and churn calculations. + ## Mount the `/profitable` dashboard `profitable` also provides a simple dashboard to see your main business metrics. @@ -56,8 +70,9 @@ All methods return numbers that can be converted to a nicely-formatted, human-re ### Revenue metrics -- `Profitable.mrr`: Current monthly recurring run-rate from currently billable subscriptions -- `Profitable.arr`: Current annual recurring run-rate (`mrr * 12`), not trailing revenue +- `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now +- `Profitable.arr`: Annual Recurring Revenue (ARR), calculated as current `mrr * 12`, not trailing revenue +- `Profitable.ttm`: Founder-friendly shorthand alias for `ttm_revenue` - `Profitable.ttm_revenue`: Trailing twelve-month revenue, net of refunds when `amount_refunded` is present - `Profitable.revenue_run_rate(in_the_last: 30.days)`: Recent revenue annualized (useful for secondary TrustMRR-style revenue multiples) - `Profitable.all_time_revenue`: Net revenue since launch @@ -118,6 +133,9 @@ Profitable.estimated_arr_valuation(multiple: 5).to_readable # => "$500,000" # Get trailing twelve-month revenue Profitable.ttm_revenue.to_readable # => "$123,456" +# Founder-friendly shorthand for trailing twelve-month revenue +Profitable.ttm.to_readable # => "$123,456" + # Get recent revenue annualized (useful for secondary TrustMRR-style revenue multiples) Profitable.revenue_run_rate(in_the_last: 30.days).to_readable # => "$96,000" @@ -157,14 +175,14 @@ Revenue methods are net of refunds when `amount_refunded` is present on `pay_cha ## Metric Guide: TTM, Revenue, Profit, ARR, and MRR -`profitable` now exposes both run-rate metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose. +`profitable` now exposes both standard recurring revenue metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose. These metrics are related, but they are not interchangeable: | Metric | What it means | Best for | What it is **not** | | --- | --- | --- | --- | -| `MRR` | Current monthly recurring run-rate from active subscriptions | Operating cadence, near-term momentum, tracking upgrades/downgrades | Monthly cash collected from all sources | -| `ARR` | Current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | Historical last-12-month revenue | +| `MRR` | Monthly Recurring Revenue from subscriptions that are billable right now | Operating cadence, near-term momentum, tracking upgrades/downgrades | Monthly cash collected from all sources | +| `ARR` | Annual Recurring Revenue, calculated as the current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | Historical last-12-month revenue | | `MRR * 12` | Simple annualization of the current monthly recurring base | Fast ARR approximation when the base is normalized monthly | TTM revenue or TTM profit | | `TTM revenue` | Actual revenue collected over the last 12 months | Buyer-facing historical actuals, smoothing seasonality, sanity-checking ARR | Forward recurring run-rate | | `TTM profit` | Actual profit over the last 12 months | Small bootstrapped SaaS exits, ROI-minded buyers, earnings-based multiples | Something `profitable` can derive from `pay` alone | @@ -176,6 +194,7 @@ These metrics are related, but they are not interchangeable: - `TTM revenue` tells you what customers actually paid over the last year. - `TTM profit` tells you what the business actually kept after costs. This is often what smaller acquisition buyers care about most, but it requires cost data outside `pay`. - In acquire-style market reports, `TTM` can refer to **both** `TTM profit` and `TTM revenue` depending on the chart. The denominator must always be stated explicitly. +- In `profitable`, the shorthand method `ttm` is defined to mean `ttm_revenue` because the gem does not yet model costs or profit. In other words: @@ -185,8 +204,9 @@ In other words: ### What `profitable` Computes -- `Profitable.mrr`: current monthly recurring run-rate from subscriptions that are billable right now -- `Profitable.arr`: current annual recurring run-rate +- `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now +- `Profitable.arr`: Annual Recurring Revenue (ARR), calculated from current MRR +- `Profitable.ttm`: shorthand alias for `ttm_revenue` - `Profitable.ttm_revenue`: trailing 12-month revenue, net of refunds when `amount_refunded` is present - `Profitable.revenue_run_rate`: recent revenue annualized to a yearly run-rate - `Profitable.estimated_valuation`: a backwards-compatible ARR-multiple heuristic diff --git a/lib/profitable.rb b/lib/profitable.rb index fb10bb8..95f3d1c 100644 --- a/lib/profitable.rb +++ b/lib/profitable.rb @@ -12,657 +12,4 @@ require "active_support/core_ext/numeric/conversions" require "action_view" -module Profitable - # Pay exposes some processor-specific status variants beyond the core generic list. - # We normalize them into business-meaningful groups so current-state metrics, - # historical event metrics, and churn denominators all behave consistently. - TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze - CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze - NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze - - class << self - include ActionView::Helpers::NumberHelper - include Profitable::JsonHelpers - - DEFAULT_PERIOD = 30.days - MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000] - - # Current monthly recurring run-rate from active subscriptions. - # Useful for operating momentum and near-term subscription changes. - def mrr - NumericResult.new(MrrCalculator.calculate) - end - - # Current annual recurring run-rate. - # This is today's recurring base annualized, not historical 12-month revenue. - def arr - NumericResult.new(calculate_arr) - end - - def churn(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_churn(in_the_last), :percentage) - end - - def all_time_revenue - NumericResult.new(calculate_all_time_revenue) - end - - # Trailing twelve-month revenue reflects actual cash collected in the last year. - # It complements ARR, which annualizes the current recurring base. - def ttm_revenue - revenue_in_period(in_the_last: 12.months) - end - - # Historical revenue collected over a rolling period. - # Unlike ARR, this is trailing actual revenue rather than a projection. - def revenue_in_period(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_revenue_in_period(in_the_last)) - end - - def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_recurring_revenue_in_period(in_the_last)) - end - - def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) - end - - def revenue_run_rate(in_the_last: 30.days) - NumericResult.new(calculate_revenue_run_rate(in_the_last)) - end - - # Backwards-compatible ARR-multiple heuristic for a quick valuation estimate. - # This is intentionally simple and should not be treated as a market appraisal. - def estimated_valuation(multiplier = nil, at: nil, multiple: nil) - estimated_arr_valuation(multiplier, at:, multiple:) - end - - def estimated_arr_valuation(multiplier = nil, at: nil, multiple: nil) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation_from(arr.to_i, actual_multiplier)) - end - - def estimated_ttm_revenue_valuation(multiplier = nil, at: nil, multiple: nil) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier)) - end - - def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier)) - end - - # Customers who have actually monetized: either a paid charge or a subscription - # that has crossed into a billable state. - def total_customers - NumericResult.new(calculate_total_customers, :integer) - end - - # Customers who have ever had a paid subscription. Trial-only subscriptions do not count. - def total_subscribers - NumericResult.new(calculate_total_subscribers, :integer) - end - - # Customers with subscriptions that are billable right now. - # Excludes free trials, paused subscriptions, and churned subscriptions. - def active_subscribers - NumericResult.new(calculate_active_subscribers, :integer) - end - - # First-time customers added in the period, based on first monetization date - # rather than signup date. - def new_customers(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_new_customers(in_the_last), :integer) - end - - # Customers whose subscriptions first became billable in the period. - # Trial starts do not count until the trial ends. - def new_subscribers(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_new_subscribers(in_the_last), :integer) - end - - def churned_customers(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_churned_customers(in_the_last), :integer) - end - - # Full monthly value of subscriptions that became billable in the period. - # This is a flow metric, so it still counts subscriptions that churned later in the same window. - def new_mrr(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_new_mrr(in_the_last)) - end - - def churned_mrr(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_churned_mrr(in_the_last)) - end - - def average_revenue_per_customer - NumericResult.new(calculate_average_revenue_per_customer) - end - - def lifetime_value - NumericResult.new(calculate_lifetime_value) - end - - def mrr_growth(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_mrr_growth(in_the_last)) - end - - def mrr_growth_rate(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage) - end - - def time_to_next_mrr_milestone - current_mrr = (mrr.to_i) / 100 # Convert cents to dollars - return "Unable to calculate. No MRR yet." if current_mrr <= 0 - - next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr } - return "Congratulations! You've reached the highest milestone." unless next_milestone - - monthly_growth_rate = calculate_mrr_growth_rate / 100 - return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0 - - # Convert monthly growth rate to daily growth rate - daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1 - return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0 - - # Calculate the number of days to reach the next milestone - days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil - - target_date = Time.current + days_to_milestone.days - - "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})" - end - - def monthly_summary(months: 12) - calculate_monthly_summary(months) - end - - def daily_summary(days: 30) - calculate_daily_summary(days) - end - - def period_data(in_the_last: DEFAULT_PERIOD) - calculate_period_data(in_the_last) - end - - private - - # Helper to load subscriptions with processor info from customer - def subscriptions_with_processor(scope = Pay::Subscription.all) - scope - .includes(:customer) - .select('pay_subscriptions.*, pay_customers.processor as customer_processor') - .joins(:customer) - end - - # Business semantics: a subscription becomes "real" for subscriber / new MRR - # reporting when billing starts. For trialless subscriptions that is created_at; - # for trials it is trial_ends_at. - def subscription_became_billable_at_sql - 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)' - end - - # We intentionally do not reuse Pay::Subscription.active here. - # Pay's active scope is access-oriented and can include free-trial access, - # while profitable needs billable subscription semantics for metrics. - def subscription_is_billable_by(date, scope = Pay::Subscription.all) - scope - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) - .where( - "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", - TRIAL_SUBSCRIPTION_STATUSES, - date - ) - .where( - "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)", - CHURNED_STATUSES - ) - .where( - "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)", - 'paused' - ) - end - - # Any subscription that has ever crossed into a paid/billable state, - # even if it later churned. This is used for "ever" style counts. - def ever_billable_subscription_scope(scope = Pay::Subscription.all) - subscription_is_billable_by(Time.current, scope) - .where("#{subscription_became_billable_at_sql} <= ?", Time.current) - end - - # Subscriptions that were billable at a historical point in time. - # This powers MRR snapshots, churn denominators, and other period math. - def billable_subscription_scope_at(date, scope = Pay::Subscription.all) - subscription_is_billable_by(date, scope) - .where("#{subscription_became_billable_at_sql} <= ?", date) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) - .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) - end - - # Current billable subscriptions. A future ends_at or future pause start means - # the subscription is still billable today and should remain in MRR / ARR. - def current_billable_subscription_scope(scope = Pay::Subscription.all) - billable_subscription_scope_at(Time.current, scope) - end - - # Historical "new subscriber" / "new MRR" event window. - # The event date is when billing starts, not when the subscription record is created. - def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) - subscription_is_billable_by(period_end, scope) - .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) - end - - def subscription_became_billable_at(subscription) - subscription.trial_ends_at || subscription.created_at - end - - def paid_charges - # Pay gem v10+ stores charge data in `object` column, older versions used `data` - # We check both columns for backwards compatibility using database-agnostic JSON extraction - # - # Performance note: The COALESCE pattern may prevent index usage on some databases. - # This is an acceptable tradeoff for backwards compatibility with Pay < 10. - # For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+ - # where only the `object` column is used. - - # Build JSON extraction SQL for both object and data columns - paid_object = json_extract('pay_charges.object', 'paid') - paid_data = json_extract('pay_charges.data', 'paid') - status_object = json_extract('pay_charges.object', 'status') - status_data = json_extract('pay_charges.data', 'status') - - Pay::Charge - .where("pay_charges.amount > 0") - .where(<<~SQL.squish, 'false', 'succeeded') - ( - (COALESCE(#{paid_object}, #{paid_data}) IS NULL - OR COALESCE(#{paid_object}, #{paid_data}) != ?) - ) - AND - ( - COALESCE(#{status_object}, #{status_data}) = ? - OR COALESCE(#{status_object}, #{status_data}) IS NULL - ) - SQL - end - - # Revenue metrics should reflect net cash collected, not gross billed amounts. - # When Pay stores refunded cents on the charge, subtract them from revenue. - def net_revenue(scope) - scope.sum(net_charge_amount_sql) - end - - def net_charge_amount_sql - "pay_charges.amount - COALESCE(pay_charges.amount_refunded, 0)" - end - - def calculate_all_time_revenue - net_revenue(paid_charges) - end - - def calculate_arr - (mrr.to_f * 12).round - end - - def calculate_estimated_valuation(multiplier = 3) - calculate_estimated_valuation_from(calculate_arr, multiplier) - end - - def calculate_estimated_valuation_from(base_amount, multiplier = 3) - multiplier = parse_multiplier(multiplier) - (base_amount * multiplier).round - end - - def parse_multiplier(input) - case input - when Numeric - input.to_f - when String - if input.end_with?('x') - input.chomp('x').to_f - else - input.to_f - end - else - 3.0 # Default multiplier if input is invalid - end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range - end - - def calculate_churn(period = DEFAULT_PERIOD) - calculate_churn_rate_for_period(period.ago, Time.current) - end - - def calculate_churned_customers(period = DEFAULT_PERIOD) - calculate_churned_subscribers_in_period(period.ago, Time.current) - end - - def calculate_churned_mrr(period = DEFAULT_PERIOD) - calculate_churned_mrr_in_period(period.ago, Time.current) - end - - def calculate_new_mrr(period = DEFAULT_PERIOD) - calculate_new_mrr_in_period(period.ago, Time.current) - end - - def calculate_revenue_in_period(period) - net_revenue(paid_charges.where(created_at: period.ago..Time.current)) - end - - def calculate_revenue_run_rate(period) - return 0 if period.to_i <= 0 - - # TrustMRR-style revenue multiples are usually quoted against recent monthly - # revenue annualized, so we normalize to a 30-day month and multiply by 12. - monthly_revenue = calculate_revenue_in_period(period).to_f * (30.days.to_f / period.to_f) - (monthly_revenue * 12).round - end - - def calculate_recurring_revenue_in_period(period) - net_revenue( - paid_charges - .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') - .where(created_at: period.ago..Time.current) - ) - end - - def calculate_recurring_revenue_percentage(period) - total_revenue = calculate_revenue_in_period(period) - recurring_revenue = calculate_recurring_revenue_in_period(period) - - return 0 if total_revenue.zero? - - ((recurring_revenue.to_f / total_revenue) * 100).round(2) - end - - def calculate_total_customers - actual_customers.count - end - - def calculate_total_subscribers - ever_billable_subscription_scope.distinct.count(:customer_id) - end - - def calculate_active_subscribers - current_billable_subscription_scope.distinct.count(:customer_id) - end - - def actual_customers - # A "customer" here means a monetized customer, not just an account record. - # We therefore union paid one-off/charge customers with customers whose - # subscriptions have reached a billable state. - customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id)) - customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id)) - - customers_with_paid_charges.or(customers_with_billable_subscriptions).distinct - end - - def calculate_new_customers(period) - period_start = period.ago - period_end = Time.current - - # "New customer" is defined by first monetization date. - # We intentionally do not use Pay::Customer.created_at because a user might - # sign up long before they ever pay or convert from trial. - first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at) - first_subscription_dates = ever_billable_subscription_scope - .group(:customer_id) - .minimum(Arel.sql(subscription_became_billable_at_sql)) - - customer_ids = first_charge_dates.keys | first_subscription_dates.keys - - customer_ids.count do |customer_id| - first_customer_date = [first_charge_dates[customer_id], first_subscription_dates[customer_id]].compact.min - first_customer_date && first_customer_date >= period_start && first_customer_date <= period_end - end - end - - def calculate_new_subscribers(period) - calculate_new_subscribers_in_period(period.ago, Time.current) - end - - def calculate_average_revenue_per_customer - paying_customers = calculate_total_customers - return 0 if paying_customers.zero? - (all_time_revenue.to_f / paying_customers).round - end - - def calculate_lifetime_value - # LTV = Monthly ARPU / Monthly Churn Rate - # where ARPU (Average Revenue Per User) = MRR / active subscribers - subscribers = calculate_active_subscribers - return 0 if subscribers.zero? - - monthly_arpu = mrr.to_f / subscribers # in cents - churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05) - return 0 if churn_rate.zero? - - (monthly_arpu / churn_rate).round # LTV in cents - end - - def calculate_mrr_growth(period = DEFAULT_PERIOD) - new_mrr = calculate_new_mrr(period) - churned_mrr = calculate_churned_mrr(period) - new_mrr - churned_mrr - end - - def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) - end_date = Time.current - start_date = end_date - period - - start_mrr = calculate_mrr_at(start_date) - end_mrr = calculate_mrr_at(end_date) - - return 0 if start_mrr == 0 - ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2) - end - - def calculate_mrr_at(date) - # Find subscriptions that were active AT the given date: - # - Started billing before or on that date - # - Not ended before that date (ends_at is nil OR ends_at > date) - # - Not paused at that date - # - Not still in a free trial at that date - subscriptions_with_processor( - billable_subscription_scope_at(date) - ).sum do |subscription| - MrrCalculator.process_subscription(subscription) - end - end - - def calculate_period_data(period) - period_start = period.ago - period_end = Time.current - - # Keep these values delegated to the same underlying helpers used by the - # public methods so the dashboard and direct API calls stay in lockstep. - new_customers_count = calculate_new_customers(period) - churned_count = calculate_churned_subscribers_in_period(period_start, period_end) - new_mrr_val = calculate_new_mrr_in_period(period_start, period_end) - churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end) - revenue_val = net_revenue(paid_charges.where(created_at: period_start..period_end)) - - # Churn rate (reuses churned_count) - total_at_start = billable_subscription_scope_at(period_start) - .distinct - .count('customer_id') - churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 - - { - new_customers: NumericResult.new(new_customers_count, :integer), - churned_customers: NumericResult.new(churned_count, :integer), - churn: NumericResult.new(churn_rate, :percentage), - new_mrr: NumericResult.new(new_mrr_val), - churned_mrr: NumericResult.new(churned_mrr_val), - mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val), - revenue: NumericResult.new(revenue_val) - } - end - - # Batched: loads all data in 5 queries then groups by month in Ruby - def calculate_monthly_summary(months_count) - overall_start = (months_count - 1).months.ago.beginning_of_month - overall_end = Time.current.end_of_month - - # Bulk load all data for the full range, then group in Ruby. - # This keeps the dashboard query count low while preserving the same - # billable-date semantics used by the single-metric helpers. - new_sub_records = Pay::Subscription - .merge(billable_subscription_events_in_period(overall_start, overall_end)) - .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) - - churned_sub_records = Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: overall_start..overall_end) - .pluck(:customer_id, :ends_at) - - new_mrr_subs = subscriptions_with_processor( - billable_subscription_events_in_period(overall_start, overall_end) - ).to_a - - churned_mrr_subs = subscriptions_with_processor( - Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: overall_start..overall_end) - ).to_a - - churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start) - .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at) - - # Group by month in Ruby using billable-at and ends_at as the event dates, - # rather than raw subscription created_at. - summary = [] - (months_count - 1).downto(0) do |months_ago| - month_start = months_ago.months.ago.beginning_of_month - month_end = month_start.end_of_month - - new_count = new_sub_records - .select { |_, created_at| created_at >= month_start && created_at <= month_end } - .map(&:first).uniq.count - - churned_count = churned_sub_records - .select { |_, ends_at| ends_at >= month_start && ends_at <= month_end } - .map(&:first).uniq.count - - new_mrr_amount = new_mrr_subs - .select { |s| subscription_became_billable_at(s) >= month_start && subscription_became_billable_at(s) <= month_end } - .sum { |s| MrrCalculator.process_subscription(s) } - - churned_mrr_amount = churned_mrr_subs - .select { |s| s.ends_at >= month_start && s.ends_at <= month_end } - .sum { |s| MrrCalculator.process_subscription(s) } - - total_at_start = churn_base_records - .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) } - .map(&:first).uniq.count - - churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 - - summary << { - month: month_start.strftime('%Y-%m'), - month_date: month_start, - new_subscribers: new_count, - churned_subscribers: churned_count, - net_subscribers: new_count - churned_count, - new_mrr: new_mrr_amount, - churned_mrr: churned_mrr_amount, - net_mrr: new_mrr_amount - churned_mrr_amount, - churn_rate: churn_rate - } - end - - summary - end - - # Batched: loads all data in 2 queries then groups by day in Ruby - def calculate_daily_summary(days_count) - overall_start = (days_count - 1).days.ago.beginning_of_day - overall_end = Time.current.end_of_day - - # Daily summary intentionally uses the same "became billable" event date as - # new_subscribers/new_mrr, so trial starts do not appear as paid conversions. - new_sub_records = Pay::Subscription - .merge(billable_subscription_events_in_period(overall_start, overall_end)) - .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) - - churned_sub_records = Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: overall_start..overall_end) - .pluck(:customer_id, :ends_at) - - summary = [] - (days_count - 1).downto(0) do |days_ago| - day_start = days_ago.days.ago.beginning_of_day - day_end = day_start.end_of_day - - new_count = new_sub_records - .select { |_, created_at| created_at >= day_start && created_at <= day_end } - .map(&:first).uniq.count - - churned_count = churned_sub_records - .select { |_, ends_at| ends_at >= day_start && ends_at <= day_end } - .map(&:first).uniq.count - - summary << { - date: day_start.to_date, - new_subscribers: new_count, - churned_subscribers: churned_count - } - end - - summary - end - - # Consolidated methods that work with any date range - def calculate_new_subscribers_in_period(period_start, period_end) - billable_subscription_events_in_period(period_start, period_end) - .distinct - .count(:customer_id) - end - - def calculate_churned_subscribers_in_period(period_start, period_end) - # Churn happens when access/billing actually ends, which Pay stores on ends_at. - Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: period_start..period_end) - .distinct - .count('customer_id') - end - - def calculate_new_mrr_in_period(period_start, period_end) - # New MRR is the full fixed monthly value of subscriptions whose billing - # started in the window. It is not prorated, and it still counts if the - # subscription churns later in the same period. - subscriptions_with_processor( - billable_subscription_events_in_period(period_start, period_end) - ).sum do |subscription| - MrrCalculator.process_subscription(subscription) - end - end - - def calculate_churned_mrr_in_period(period_start, period_end) - # Churned MRR is the full fixed monthly value being lost at churn time. - subscriptions_with_processor( - Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: period_start..period_end) - ).sum do |subscription| - MrrCalculator.process_subscription(subscription) - end - end - - def calculate_churn_rate_for_period(period_start, period_end) - # Count subscribers who were billable at the start of the period. - # This keeps free trials and not-yet-paying subscriptions out of the denominator. - total_subscribers_start = billable_subscription_scope_at(period_start) - .distinct - .count('customer_id') - - churned = calculate_churned_subscribers_in_period(period_start, period_end) - return 0 if total_subscribers_start == 0 - - (churned.to_f / total_subscribers_start * 100).round(1) - end - - end -end +require_relative "profitable/metrics" diff --git a/lib/profitable/metrics.rb b/lib/profitable/metrics.rb new file mode 100644 index 0000000..a8eb879 --- /dev/null +++ b/lib/profitable/metrics.rb @@ -0,0 +1,664 @@ +# frozen_string_literal: true + +module Profitable + # Pay exposes some processor-specific status variants beyond the core generic list. + # We normalize them into business-meaningful groups so current-state metrics, + # historical event metrics, and churn denominators all behave consistently. + TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze + CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze + NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze + + class << self + include ActionView::Helpers::NumberHelper + include Profitable::JsonHelpers + + DEFAULT_PERIOD = 30.days + MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000] + + # Monthly Recurring Revenue (MRR) from subscriptions that are billable right now. + # This is a current recurring run-rate metric, useful for operating momentum + # and near-term subscription changes. + def mrr + NumericResult.new(MrrCalculator.calculate) + end + + # Annual Recurring Revenue (ARR) based on the current recurring base. + # This is today's MRR annualized, not historical 12-month revenue. + def arr + NumericResult.new(calculate_arr) + end + + def churn(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_churn(in_the_last), :percentage) + end + + def all_time_revenue + NumericResult.new(calculate_all_time_revenue) + end + + # Trailing twelve-month revenue reflects actual cash collected in the last year. + # It complements ARR, which annualizes the current recurring base. + def ttm_revenue + revenue_in_period(in_the_last: 12.months) + end + + # Founder-friendly shorthand for trailing-twelve-month revenue. + # We keep the explicit ttm_revenue name as the canonical API because bare + # "TTM" is ambiguous in finance once profit metrics enter the picture. + def ttm + ttm_revenue + end + + # Historical revenue collected over a rolling period. + # Unlike ARR, this is trailing actual revenue rather than a projection. + def revenue_in_period(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_revenue_in_period(in_the_last)) + end + + def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_recurring_revenue_in_period(in_the_last)) + end + + def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) + end + + def revenue_run_rate(in_the_last: 30.days) + NumericResult.new(calculate_revenue_run_rate(in_the_last)) + end + + # Backwards-compatible ARR-multiple heuristic for a quick valuation estimate. + # This is intentionally simple and should not be treated as a market appraisal. + def estimated_valuation(multiplier = nil, at: nil, multiple: nil) + estimated_arr_valuation(multiplier, at:, multiple:) + end + + def estimated_arr_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(arr.to_i, actual_multiplier)) + end + + def estimated_ttm_revenue_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier)) + end + + def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier)) + end + + # Customers who have actually monetized: either a paid charge or a subscription + # that has crossed into a billable state. + def total_customers + NumericResult.new(calculate_total_customers, :integer) + end + + # Customers who have ever had a paid subscription. Trial-only subscriptions do not count. + def total_subscribers + NumericResult.new(calculate_total_subscribers, :integer) + end + + # Customers with subscriptions that are billable right now. + # Excludes free trials, paused subscriptions, and churned subscriptions. + def active_subscribers + NumericResult.new(calculate_active_subscribers, :integer) + end + + # First-time customers added in the period, based on first monetization date + # rather than signup date. + def new_customers(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_new_customers(in_the_last), :integer) + end + + # Customers whose subscriptions first became billable in the period. + # Trial starts do not count until the trial ends. + def new_subscribers(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_new_subscribers(in_the_last), :integer) + end + + def churned_customers(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_churned_customers(in_the_last), :integer) + end + + # Full monthly value of subscriptions that became billable in the period. + # This is a flow metric, so it still counts subscriptions that churned later in the same window. + def new_mrr(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_new_mrr(in_the_last)) + end + + def churned_mrr(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_churned_mrr(in_the_last)) + end + + def average_revenue_per_customer + NumericResult.new(calculate_average_revenue_per_customer) + end + + def lifetime_value + NumericResult.new(calculate_lifetime_value) + end + + def mrr_growth(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_mrr_growth(in_the_last)) + end + + def mrr_growth_rate(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage) + end + + def time_to_next_mrr_milestone + current_mrr = (mrr.to_i) / 100 # Convert cents to dollars + return "Unable to calculate. No MRR yet." if current_mrr <= 0 + + next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr } + return "Congratulations! You've reached the highest milestone." unless next_milestone + + monthly_growth_rate = calculate_mrr_growth_rate / 100 + return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0 + + # Convert monthly growth rate to daily growth rate + daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1 + return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0 + + # Calculate the number of days to reach the next milestone + days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil + + target_date = Time.current + days_to_milestone.days + + "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})" + end + + def monthly_summary(months: 12) + calculate_monthly_summary(months) + end + + def daily_summary(days: 30) + calculate_daily_summary(days) + end + + def period_data(in_the_last: DEFAULT_PERIOD) + calculate_period_data(in_the_last) + end + + private + + # Helper to load subscriptions with processor info from customer + def subscriptions_with_processor(scope = Pay::Subscription.all) + scope + .includes(:customer) + .select('pay_subscriptions.*, pay_customers.processor as customer_processor') + .joins(:customer) + end + + # Business semantics: a subscription becomes "real" for subscriber / new MRR + # reporting when billing starts. For trialless subscriptions that is created_at; + # for trials it is trial_ends_at. + def subscription_became_billable_at_sql + 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)' + end + + # We intentionally do not reuse Pay::Subscription.active here. + # Pay's active scope is access-oriented and can include free-trial access, + # while profitable needs billable subscription semantics for metrics. + def subscription_is_billable_by(date, scope = Pay::Subscription.all) + scope + .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) + .where( + "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", + TRIAL_SUBSCRIPTION_STATUSES, + date + ) + .where( + "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)", + CHURNED_STATUSES + ) + .where( + "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)", + 'paused' + ) + end + + # Any subscription that has ever crossed into a paid/billable state, + # even if it later churned. This is used for "ever" style counts. + def ever_billable_subscription_scope(scope = Pay::Subscription.all) + subscription_is_billable_by(Time.current, scope) + .where("#{subscription_became_billable_at_sql} <= ?", Time.current) + end + + # Subscriptions that were billable at a historical point in time. + # This powers MRR snapshots, churn denominators, and other period math. + def billable_subscription_scope_at(date, scope = Pay::Subscription.all) + subscription_is_billable_by(date, scope) + .where("#{subscription_became_billable_at_sql} <= ?", date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) + .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) + end + + # Current billable subscriptions. A future ends_at or future pause start means + # the subscription is still billable today and should remain in MRR / ARR. + def current_billable_subscription_scope(scope = Pay::Subscription.all) + billable_subscription_scope_at(Time.current, scope) + end + + # Historical "new subscriber" / "new MRR" event window. + # The event date is when billing starts, not when the subscription record is created. + def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) + subscription_is_billable_by(period_end, scope) + .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) + end + + def subscription_became_billable_at(subscription) + subscription.trial_ends_at || subscription.created_at + end + + def paid_charges + # Pay gem v10+ stores charge data in `object` column, older versions used `data` + # We check both columns for backwards compatibility using database-agnostic JSON extraction + # + # Performance note: The COALESCE pattern may prevent index usage on some databases. + # This is an acceptable tradeoff for backwards compatibility with Pay < 10. + # For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+ + # where only the `object` column is used. + + # Build JSON extraction SQL for both object and data columns + paid_object = json_extract('pay_charges.object', 'paid') + paid_data = json_extract('pay_charges.data', 'paid') + status_object = json_extract('pay_charges.object', 'status') + status_data = json_extract('pay_charges.data', 'status') + + Pay::Charge + .where("pay_charges.amount > 0") + .where(<<~SQL.squish, 'false', 'succeeded') + ( + (COALESCE(#{paid_object}, #{paid_data}) IS NULL + OR COALESCE(#{paid_object}, #{paid_data}) != ?) + ) + AND + ( + COALESCE(#{status_object}, #{status_data}) = ? + OR COALESCE(#{status_object}, #{status_data}) IS NULL + ) + SQL + end + + # Revenue metrics should reflect net cash collected, not gross billed amounts. + # When Pay stores refunded cents on the charge, subtract them from revenue. + def net_revenue(scope) + scope.sum(net_charge_amount_sql) + end + + def net_charge_amount_sql + "pay_charges.amount - COALESCE(pay_charges.amount_refunded, 0)" + end + + def calculate_all_time_revenue + net_revenue(paid_charges) + end + + def calculate_arr + (mrr.to_f * 12).round + end + + def calculate_estimated_valuation(multiplier = 3) + calculate_estimated_valuation_from(calculate_arr, multiplier) + end + + def calculate_estimated_valuation_from(base_amount, multiplier = 3) + multiplier = parse_multiplier(multiplier) + (base_amount * multiplier).round + end + + def parse_multiplier(input) + case input + when Numeric + input.to_f + when String + if input.end_with?('x') + input.chomp('x').to_f + else + input.to_f + end + else + 3.0 # Default multiplier if input is invalid + end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range + end + + def calculate_churn(period = DEFAULT_PERIOD) + calculate_churn_rate_for_period(period.ago, Time.current) + end + + def calculate_churned_customers(period = DEFAULT_PERIOD) + calculate_churned_subscribers_in_period(period.ago, Time.current) + end + + def calculate_churned_mrr(period = DEFAULT_PERIOD) + calculate_churned_mrr_in_period(period.ago, Time.current) + end + + def calculate_new_mrr(period = DEFAULT_PERIOD) + calculate_new_mrr_in_period(period.ago, Time.current) + end + + def calculate_revenue_in_period(period) + net_revenue(paid_charges.where(created_at: period.ago..Time.current)) + end + + def calculate_revenue_run_rate(period) + return 0 if period.to_i <= 0 + + # TrustMRR-style revenue multiples are usually quoted against recent monthly + # revenue annualized, so we normalize to a 30-day month and multiply by 12. + monthly_revenue = calculate_revenue_in_period(period).to_f * (30.days.to_f / period.to_f) + (monthly_revenue * 12).round + end + + def calculate_recurring_revenue_in_period(period) + net_revenue( + paid_charges + .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') + .where(created_at: period.ago..Time.current) + ) + end + + def calculate_recurring_revenue_percentage(period) + total_revenue = calculate_revenue_in_period(period) + recurring_revenue = calculate_recurring_revenue_in_period(period) + + return 0 if total_revenue.zero? + + ((recurring_revenue.to_f / total_revenue) * 100).round(2) + end + + def calculate_total_customers + actual_customers.count + end + + def calculate_total_subscribers + ever_billable_subscription_scope.distinct.count(:customer_id) + end + + def calculate_active_subscribers + current_billable_subscription_scope.distinct.count(:customer_id) + end + + def actual_customers + # A "customer" here means a monetized customer, not just an account record. + # We therefore union paid one-off/charge customers with customers whose + # subscriptions have reached a billable state. + customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id)) + customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id)) + + customers_with_paid_charges.or(customers_with_billable_subscriptions).distinct + end + + def calculate_new_customers(period) + period_start = period.ago + period_end = Time.current + + # "New customer" is defined by first monetization date. + # We intentionally do not use Pay::Customer.created_at because a user might + # sign up long before they ever pay or convert from trial. + first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at) + first_subscription_dates = ever_billable_subscription_scope + .group(:customer_id) + .minimum(Arel.sql(subscription_became_billable_at_sql)) + + customer_ids = first_charge_dates.keys | first_subscription_dates.keys + + customer_ids.count do |customer_id| + first_customer_date = [first_charge_dates[customer_id], first_subscription_dates[customer_id]].compact.min + first_customer_date && first_customer_date >= period_start && first_customer_date <= period_end + end + end + + def calculate_new_subscribers(period) + calculate_new_subscribers_in_period(period.ago, Time.current) + end + + def calculate_average_revenue_per_customer + paying_customers = calculate_total_customers + return 0 if paying_customers.zero? + (all_time_revenue.to_f / paying_customers).round + end + + def calculate_lifetime_value + # LTV = Monthly ARPU / Monthly Churn Rate + # where ARPU (Average Revenue Per User) = MRR / active subscribers + subscribers = calculate_active_subscribers + return 0 if subscribers.zero? + + monthly_arpu = mrr.to_f / subscribers # in cents + churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05) + return 0 if churn_rate.zero? + + (monthly_arpu / churn_rate).round # LTV in cents + end + + def calculate_mrr_growth(period = DEFAULT_PERIOD) + new_mrr = calculate_new_mrr(period) + churned_mrr = calculate_churned_mrr(period) + new_mrr - churned_mrr + end + + def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) + end_date = Time.current + start_date = end_date - period + + start_mrr = calculate_mrr_at(start_date) + end_mrr = calculate_mrr_at(end_date) + + return 0 if start_mrr == 0 + ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2) + end + + def calculate_mrr_at(date) + # Find subscriptions that were active AT the given date: + # - Started billing before or on that date + # - Not ended before that date (ends_at is nil OR ends_at > date) + # - Not paused at that date + # - Not still in a free trial at that date + subscriptions_with_processor( + billable_subscription_scope_at(date) + ).sum do |subscription| + MrrCalculator.process_subscription(subscription) + end + end + + def calculate_period_data(period) + period_start = period.ago + period_end = Time.current + + # Keep these values delegated to the same underlying helpers used by the + # public methods so the dashboard and direct API calls stay in lockstep. + new_customers_count = calculate_new_customers(period) + churned_count = calculate_churned_subscribers_in_period(period_start, period_end) + new_mrr_val = calculate_new_mrr_in_period(period_start, period_end) + churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end) + revenue_val = net_revenue(paid_charges.where(created_at: period_start..period_end)) + + # Churn rate (reuses churned_count) + total_at_start = billable_subscription_scope_at(period_start) + .distinct + .count('customer_id') + churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 + + { + new_customers: NumericResult.new(new_customers_count, :integer), + churned_customers: NumericResult.new(churned_count, :integer), + churn: NumericResult.new(churn_rate, :percentage), + new_mrr: NumericResult.new(new_mrr_val), + churned_mrr: NumericResult.new(churned_mrr_val), + mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val), + revenue: NumericResult.new(revenue_val) + } + end + + # Batched: loads all data in 5 queries then groups by month in Ruby + def calculate_monthly_summary(months_count) + overall_start = (months_count - 1).months.ago.beginning_of_month + overall_end = Time.current.end_of_month + + # Bulk load all data for the full range, then group in Ruby. + # This keeps the dashboard query count low while preserving the same + # billable-date semantics used by the single-metric helpers. + new_sub_records = Pay::Subscription + .merge(billable_subscription_events_in_period(overall_start, overall_end)) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) + + churned_sub_records = Pay::Subscription + .where(status: CHURNED_STATUSES) + .where(ends_at: overall_start..overall_end) + .pluck(:customer_id, :ends_at) + + new_mrr_subs = subscriptions_with_processor( + billable_subscription_events_in_period(overall_start, overall_end) + ).to_a + + churned_mrr_subs = subscriptions_with_processor( + Pay::Subscription + .where(status: CHURNED_STATUSES) + .where(ends_at: overall_start..overall_end) + ).to_a + + churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at) + + # Group by month in Ruby using billable-at and ends_at as the event dates, + # rather than raw subscription created_at. + summary = [] + (months_count - 1).downto(0) do |months_ago| + month_start = months_ago.months.ago.beginning_of_month + month_end = month_start.end_of_month + + new_count = new_sub_records + .select { |_, created_at| created_at >= month_start && created_at <= month_end } + .map(&:first).uniq.count + + churned_count = churned_sub_records + .select { |_, ends_at| ends_at >= month_start && ends_at <= month_end } + .map(&:first).uniq.count + + new_mrr_amount = new_mrr_subs + .select { |s| subscription_became_billable_at(s) >= month_start && subscription_became_billable_at(s) <= month_end } + .sum { |s| MrrCalculator.process_subscription(s) } + + churned_mrr_amount = churned_mrr_subs + .select { |s| s.ends_at >= month_start && s.ends_at <= month_end } + .sum { |s| MrrCalculator.process_subscription(s) } + + total_at_start = churn_base_records + .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) } + .map(&:first).uniq.count + + churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 + + summary << { + month: month_start.strftime('%Y-%m'), + month_date: month_start, + new_subscribers: new_count, + churned_subscribers: churned_count, + net_subscribers: new_count - churned_count, + new_mrr: new_mrr_amount, + churned_mrr: churned_mrr_amount, + net_mrr: new_mrr_amount - churned_mrr_amount, + churn_rate: churn_rate + } + end + + summary + end + + # Batched: loads all data in 2 queries then groups by day in Ruby + def calculate_daily_summary(days_count) + overall_start = (days_count - 1).days.ago.beginning_of_day + overall_end = Time.current.end_of_day + + # Daily summary intentionally uses the same "became billable" event date as + # new_subscribers/new_mrr, so trial starts do not appear as paid conversions. + new_sub_records = Pay::Subscription + .merge(billable_subscription_events_in_period(overall_start, overall_end)) + .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) + + churned_sub_records = Pay::Subscription + .where(status: CHURNED_STATUSES) + .where(ends_at: overall_start..overall_end) + .pluck(:customer_id, :ends_at) + + summary = [] + (days_count - 1).downto(0) do |days_ago| + day_start = days_ago.days.ago.beginning_of_day + day_end = day_start.end_of_day + + new_count = new_sub_records + .select { |_, created_at| created_at >= day_start && created_at <= day_end } + .map(&:first).uniq.count + + churned_count = churned_sub_records + .select { |_, ends_at| ends_at >= day_start && ends_at <= day_end } + .map(&:first).uniq.count + + summary << { + date: day_start.to_date, + new_subscribers: new_count, + churned_subscribers: churned_count + } + end + + summary + end + + # Consolidated methods that work with any date range + def calculate_new_subscribers_in_period(period_start, period_end) + billable_subscription_events_in_period(period_start, period_end) + .distinct + .count(:customer_id) + end + + def calculate_churned_subscribers_in_period(period_start, period_end) + # Churn happens when access/billing actually ends, which Pay stores on ends_at. + Pay::Subscription + .where(status: CHURNED_STATUSES) + .where(ends_at: period_start..period_end) + .distinct + .count('customer_id') + end + + def calculate_new_mrr_in_period(period_start, period_end) + # New MRR is the full fixed monthly value of subscriptions whose billing + # started in the window. It is not prorated, and it still counts if the + # subscription churns later in the same period. + subscriptions_with_processor( + billable_subscription_events_in_period(period_start, period_end) + ).sum do |subscription| + MrrCalculator.process_subscription(subscription) + end + end + + def calculate_churned_mrr_in_period(period_start, period_end) + # Churned MRR is the full fixed monthly value being lost at churn time. + subscriptions_with_processor( + Pay::Subscription + .where(status: CHURNED_STATUSES) + .where(ends_at: period_start..period_end) + ).sum do |subscription| + MrrCalculator.process_subscription(subscription) + end + end + + def calculate_churn_rate_for_period(period_start, period_end) + # Count subscribers who were billable at the start of the period. + # This keeps free trials and not-yet-paying subscriptions out of the denominator. + total_subscribers_start = billable_subscription_scope_at(period_start) + .distinct + .count('customer_id') + + churned = calculate_churned_subscribers_in_period(period_start, period_end) + return 0 if total_subscribers_start == 0 + + (churned.to_f / total_subscribers_start * 100).round(1) + end + + end +end diff --git a/test/profitable_test.rb b/test/profitable_test.rb index f821d6a..b38b870 100644 --- a/test/profitable_test.rb +++ b/test/profitable_test.rb @@ -261,6 +261,14 @@ def test_ttm_revenue_returns_numeric_result assert_kind_of Profitable::NumericResult, Profitable.ttm_revenue end + def test_ttm_alias_returns_same_value_as_ttm_revenue + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge(customer: @customer, amount: 3000) + + assert_kind_of Profitable::NumericResult, Profitable.ttm + assert_equal Profitable.ttm_revenue.to_i, Profitable.ttm.to_i + end + def test_ttm_revenue_only_includes_last_twelve_months_and_subtracts_refunds old_charge = create_successful_charge(customer: @customer, amount: 10000) old_charge.update!(created_at: 13.months.ago) diff --git a/test/test_helper.rb b/test/test_helper.rb index 10da06b..11898f7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -118,610 +118,19 @@ class Charge < ActiveRecord::Base end end -# Now require the profitable gem components (skip engine) +# Now require the profitable gem components. +# We intentionally load the shared metrics implementation directly instead of +# mirroring the Profitable module in tests. This keeps the standalone test +# harness exercising the real production code paths. require_relative "../lib/profitable/version" require_relative "../lib/profitable/error" require_relative "../lib/profitable/mrr_calculator" require_relative "../lib/profitable/numeric_result" require_relative "../lib/profitable/json_helpers" +require_relative "../lib/profitable/metrics" require "active_support/core_ext/numeric/conversions" -# Define the Profitable module (mirroring the real implementation in lib/profitable.rb) -# IMPORTANT: This must be kept in sync with lib/profitable.rb. -# We can't load lib/profitable.rb directly because `require "pay"` loads the full -# Pay engine which needs Rails. Instead we define minimal Pay models above and -# mirror the Profitable module here. -module Profitable - # Subscription status constants (at module level so MrrCalculator can reference them) - TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze - CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze - NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze - - class << self - include ActionView::Helpers::NumberHelper - include Profitable::JsonHelpers - - DEFAULT_PERIOD = 30.days - MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000] - - def mrr - NumericResult.new(MrrCalculator.calculate) - end - - def arr - NumericResult.new(calculate_arr) - end - - def churn(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_churn(in_the_last), :percentage) - end - - def all_time_revenue - NumericResult.new(calculate_all_time_revenue) - end - - def ttm_revenue - revenue_in_period(in_the_last: 12.months) - end - - def revenue_in_period(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_revenue_in_period(in_the_last)) - end - - def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_recurring_revenue_in_period(in_the_last)) - end - - def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) - end - - def revenue_run_rate(in_the_last: 30.days) - NumericResult.new(calculate_revenue_run_rate(in_the_last)) - end - - def estimated_valuation(multiplier = nil, at: nil, multiple: nil) - estimated_arr_valuation(multiplier, at:, multiple:) - end - - def estimated_arr_valuation(multiplier = nil, at: nil, multiple: nil) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation_from(arr.to_i, actual_multiplier)) - end - - def estimated_ttm_revenue_valuation(multiplier = nil, at: nil, multiple: nil) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier)) - end - - def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier)) - end - - def total_customers - NumericResult.new(calculate_total_customers, :integer) - end - - def total_subscribers - NumericResult.new(calculate_total_subscribers, :integer) - end - - def active_subscribers - NumericResult.new(calculate_active_subscribers, :integer) - end - - def new_customers(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_new_customers(in_the_last), :integer) - end - - def new_subscribers(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_new_subscribers(in_the_last), :integer) - end - - def churned_customers(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_churned_customers(in_the_last), :integer) - end - - def new_mrr(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_new_mrr(in_the_last)) - end - - def churned_mrr(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_churned_mrr(in_the_last)) - end - - def average_revenue_per_customer - NumericResult.new(calculate_average_revenue_per_customer) - end - - def lifetime_value - NumericResult.new(calculate_lifetime_value) - end - - def mrr_growth(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_mrr_growth(in_the_last)) - end - - def mrr_growth_rate(in_the_last: DEFAULT_PERIOD) - NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage) - end - - def time_to_next_mrr_milestone - current_mrr = (mrr.to_i) / 100 # Convert cents to dollars - return "Unable to calculate. No MRR yet." if current_mrr <= 0 - - next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr } - return "Congratulations! You've reached the highest milestone." unless next_milestone - - monthly_growth_rate = calculate_mrr_growth_rate / 100 - return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0 - - # Convert monthly growth rate to daily growth rate - daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1 - return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0 - - # Calculate the number of days to reach the next milestone - days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil - - target_date = Time.current + days_to_milestone.days - - "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})" - end - - def monthly_summary(months: 12) - calculate_monthly_summary(months) - end - - def daily_summary(days: 30) - calculate_daily_summary(days) - end - - def period_data(in_the_last: DEFAULT_PERIOD) - calculate_period_data(in_the_last) - end - - private - - # Helper to load subscriptions with processor info from customer - def subscriptions_with_processor(scope = Pay::Subscription.all) - scope - .includes(:customer) - .select('pay_subscriptions.*, pay_customers.processor as customer_processor') - .joins(:customer) - end - - def subscription_became_billable_at_sql - 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)' - end - - def subscription_is_billable_by(date, scope = Pay::Subscription.all) - scope - .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES) - .where( - "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))", - TRIAL_SUBSCRIPTION_STATUSES, - date - ) - .where( - "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)", - CHURNED_STATUSES - ) - .where( - "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)", - 'paused' - ) - end - - def ever_billable_subscription_scope(scope = Pay::Subscription.all) - subscription_is_billable_by(Time.current, scope) - .where("#{subscription_became_billable_at_sql} <= ?", Time.current) - end - - def billable_subscription_scope_at(date, scope = Pay::Subscription.all) - subscription_is_billable_by(date, scope) - .where("#{subscription_became_billable_at_sql} <= ?", date) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) - .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) - end - - def current_billable_subscription_scope(scope = Pay::Subscription.all) - billable_subscription_scope_at(Time.current, scope) - end - - def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all) - subscription_is_billable_by(period_end, scope) - .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end) - end - - def subscription_became_billable_at(subscription) - subscription.trial_ends_at || subscription.created_at - end - - def paid_charges - # Pay gem v10+ stores charge data in `object` column, older versions used `data` - # We check both columns for backwards compatibility using database-agnostic JSON extraction - - # Build JSON extraction SQL for both object and data columns - paid_object = json_extract('pay_charges.object', 'paid') - paid_data = json_extract('pay_charges.data', 'paid') - status_object = json_extract('pay_charges.object', 'status') - status_data = json_extract('pay_charges.data', 'status') - - Pay::Charge - .where("pay_charges.amount > 0") - .where(<<~SQL.squish, 'false', 'succeeded') - ( - (COALESCE(#{paid_object}, #{paid_data}) IS NULL - OR COALESCE(#{paid_object}, #{paid_data}) != ?) - ) - AND - ( - COALESCE(#{status_object}, #{status_data}) = ? - OR COALESCE(#{status_object}, #{status_data}) IS NULL - ) - SQL - end - - def net_revenue(scope) - scope.sum(net_charge_amount_sql) - end - - def net_charge_amount_sql - "pay_charges.amount - COALESCE(pay_charges.amount_refunded, 0)" - end - - def calculate_all_time_revenue - net_revenue(paid_charges) - end - - def calculate_arr - (mrr.to_f * 12).round - end - - def calculate_estimated_valuation(multiplier = 3) - calculate_estimated_valuation_from(calculate_arr, multiplier) - end - - def calculate_estimated_valuation_from(base_amount, multiplier = 3) - multiplier = parse_multiplier(multiplier) - (base_amount * multiplier).round - end - - def parse_multiplier(input) - case input - when Numeric - input.to_f - when String - if input.end_with?('x') - input.chomp('x').to_f - else - input.to_f - end - else - 3.0 # Default multiplier if input is invalid - end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range - end - - def calculate_churn(period = DEFAULT_PERIOD) - calculate_churn_rate_for_period(period.ago, Time.current) - end - - def calculate_churned_customers(period = DEFAULT_PERIOD) - calculate_churned_subscribers_in_period(period.ago, Time.current) - end - - def calculate_churned_mrr(period = DEFAULT_PERIOD) - calculate_churned_mrr_in_period(period.ago, Time.current) - end - - def calculate_new_mrr(period = DEFAULT_PERIOD) - calculate_new_mrr_in_period(period.ago, Time.current) - end - - def calculate_revenue_in_period(period) - net_revenue(paid_charges.where(created_at: period.ago..Time.current)) - end - - def calculate_revenue_run_rate(period) - return 0 if period.to_i <= 0 - - monthly_revenue = calculate_revenue_in_period(period).to_f * (30.days.to_f / period.to_f) - (monthly_revenue * 12).round - end - - def calculate_recurring_revenue_in_period(period) - net_revenue( - paid_charges - .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') - .where(created_at: period.ago..Time.current) - ) - end - - def calculate_recurring_revenue_percentage(period) - total_revenue = calculate_revenue_in_period(period) - recurring_revenue = calculate_recurring_revenue_in_period(period) - - return 0 if total_revenue.zero? - - ((recurring_revenue.to_f / total_revenue) * 100).round(2) - end - - def calculate_total_customers - actual_customers.count - end - - def calculate_total_subscribers - ever_billable_subscription_scope.distinct.count(:customer_id) - end - - def calculate_active_subscribers - current_billable_subscription_scope.distinct.count(:customer_id) - end - - def actual_customers - customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id)) - customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id)) - - customers_with_paid_charges.or(customers_with_billable_subscriptions).distinct - end - - def calculate_new_customers(period) - period_start = period.ago - period_end = Time.current - - first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at) - first_subscription_dates = ever_billable_subscription_scope - .group(:customer_id) - .minimum(Arel.sql(subscription_became_billable_at_sql)) - - customer_ids = first_charge_dates.keys | first_subscription_dates.keys - - customer_ids.count do |customer_id| - first_customer_date = [first_charge_dates[customer_id], first_subscription_dates[customer_id]].compact.min - first_customer_date && first_customer_date >= period_start && first_customer_date <= period_end - end - end - - def calculate_new_subscribers(period) - calculate_new_subscribers_in_period(period.ago, Time.current) - end - - def calculate_average_revenue_per_customer - paying_customers = calculate_total_customers - return 0 if paying_customers.zero? - (all_time_revenue.to_f / paying_customers).round - end - - def calculate_lifetime_value - # LTV = Monthly ARPU / Monthly Churn Rate - # where ARPU (Average Revenue Per User) = MRR / active subscribers - subscribers = calculate_active_subscribers - return 0 if subscribers.zero? - - monthly_arpu = mrr.to_f / subscribers # in cents - churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05) - return 0 if churn_rate.zero? - - (monthly_arpu / churn_rate).round # LTV in cents - end - - def calculate_mrr_growth(period = DEFAULT_PERIOD) - new_mrr = calculate_new_mrr(period) - churned_mrr = calculate_churned_mrr(period) - new_mrr - churned_mrr - end - - def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) - end_date = Time.current - start_date = end_date - period - - start_mrr = calculate_mrr_at(start_date) - end_mrr = calculate_mrr_at(end_date) - - return 0 if start_mrr == 0 - ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2) - end - - def calculate_mrr_at(date) - # Find subscriptions that were active AT the given date: - # - Started billing before or on that date - # - Not ended before that date (ends_at is nil OR ends_at > date) - # - Not paused at that date - # - Not still in a free trial at that date - subscriptions_with_processor( - billable_subscription_scope_at(date) - ).sum do |subscription| - MrrCalculator.process_subscription(subscription) - end - end - - def calculate_period_data(period) - period_start = period.ago - period_end = Time.current - - new_customers_count = calculate_new_customers(period) - churned_count = calculate_churned_subscribers_in_period(period_start, period_end) - new_mrr_val = calculate_new_mrr_in_period(period_start, period_end) - churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end) - revenue_val = net_revenue(paid_charges.where(created_at: period_start..period_end)) - - # Churn rate (reuses churned_count) - total_at_start = billable_subscription_scope_at(period_start) - .distinct - .count('customer_id') - churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 - - { - new_customers: NumericResult.new(new_customers_count, :integer), - churned_customers: NumericResult.new(churned_count, :integer), - churn: NumericResult.new(churn_rate, :percentage), - new_mrr: NumericResult.new(new_mrr_val), - churned_mrr: NumericResult.new(churned_mrr_val), - mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val), - revenue: NumericResult.new(revenue_val) - } - end - - # Batched: loads all data in 5 queries then groups by month in Ruby - def calculate_monthly_summary(months_count) - overall_start = (months_count - 1).months.ago.beginning_of_month - overall_end = Time.current.end_of_month - - # Bulk load all data for the full range - new_sub_records = Pay::Subscription - .merge(billable_subscription_events_in_period(overall_start, overall_end)) - .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) - - churned_sub_records = Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: overall_start..overall_end) - .pluck(:customer_id, :ends_at) - - new_mrr_subs = subscriptions_with_processor( - billable_subscription_events_in_period(overall_start, overall_end) - ).to_a - - churned_mrr_subs = subscriptions_with_processor( - Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: overall_start..overall_end) - ).to_a - - churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription) - .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start) - .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at) - - # Group by month in Ruby - summary = [] - (months_count - 1).downto(0) do |months_ago| - month_start = months_ago.months.ago.beginning_of_month - month_end = month_start.end_of_month - - new_count = new_sub_records - .select { |_, created_at| created_at >= month_start && created_at <= month_end } - .map(&:first).uniq.count - - churned_count = churned_sub_records - .select { |_, ends_at| ends_at >= month_start && ends_at <= month_end } - .map(&:first).uniq.count - - new_mrr_amount = new_mrr_subs - .select { |s| subscription_became_billable_at(s) >= month_start && subscription_became_billable_at(s) <= month_end } - .sum { |s| MrrCalculator.process_subscription(s) } - - churned_mrr_amount = churned_mrr_subs - .select { |s| s.ends_at >= month_start && s.ends_at <= month_end } - .sum { |s| MrrCalculator.process_subscription(s) } - - total_at_start = churn_base_records - .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) } - .map(&:first).uniq.count - - churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0 - - summary << { - month: month_start.strftime('%Y-%m'), - month_date: month_start, - new_subscribers: new_count, - churned_subscribers: churned_count, - net_subscribers: new_count - churned_count, - new_mrr: new_mrr_amount, - churned_mrr: churned_mrr_amount, - net_mrr: new_mrr_amount - churned_mrr_amount, - churn_rate: churn_rate - } - end - - summary - end - - # Batched: loads all data in 2 queries then groups by day in Ruby - def calculate_daily_summary(days_count) - overall_start = (days_count - 1).days.ago.beginning_of_day - overall_end = Time.current.end_of_day - - new_sub_records = Pay::Subscription - .merge(billable_subscription_events_in_period(overall_start, overall_end)) - .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql)) - - churned_sub_records = Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: overall_start..overall_end) - .pluck(:customer_id, :ends_at) - - summary = [] - (days_count - 1).downto(0) do |days_ago| - day_start = days_ago.days.ago.beginning_of_day - day_end = day_start.end_of_day - - new_count = new_sub_records - .select { |_, created_at| created_at >= day_start && created_at <= day_end } - .map(&:first).uniq.count - - churned_count = churned_sub_records - .select { |_, ends_at| ends_at >= day_start && ends_at <= day_end } - .map(&:first).uniq.count - - summary << { - date: day_start.to_date, - new_subscribers: new_count, - churned_subscribers: churned_count - } - end - - summary - end - - # Consolidated methods that work with any date range - def calculate_new_subscribers_in_period(period_start, period_end) - billable_subscription_events_in_period(period_start, period_end) - .distinct - .count(:customer_id) - end - - def calculate_churned_subscribers_in_period(period_start, period_end) - Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: period_start..period_end) - .distinct - .count('customer_id') - end - - def calculate_new_mrr_in_period(period_start, period_end) - subscriptions_with_processor( - billable_subscription_events_in_period(period_start, period_end) - ).sum do |subscription| - MrrCalculator.process_subscription(subscription) - end - end - - def calculate_churned_mrr_in_period(period_start, period_end) - subscriptions_with_processor( - Pay::Subscription - .where(status: CHURNED_STATUSES) - .where(ends_at: period_start..period_end) - ).sum do |subscription| - MrrCalculator.process_subscription(subscription) - end - end - - def calculate_churn_rate_for_period(period_start, period_end) - # Count subscribers who were active AT the start of the period - total_subscribers_start = billable_subscription_scope_at(period_start) - .distinct - .count('customer_id') - - churned = calculate_churned_subscribers_in_period(period_start, period_end) - return 0 if total_subscribers_start == 0 - - (churned.to_f / total_subscribers_start * 100).round(1) - end - - end -end - # Test helper methods module ProfitableTestHelpers # Creates a Stripe subscription with the v10+ object column structure From 695f4baba8c4f7087915f4c481231805f35d8584 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:53:28 +0000 Subject: [PATCH 5/6] Fix Claude Code Review workflow to use plain prompt instead of plugins The plugin-based approach was failing because the Claude CLI installation returns 403 and can't add marketplaces. Switch to the simpler prompt-based approach used in organizations gem which doesn't require CLI installation. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/claude-code-review.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..49d932d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,9 +36,22 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' From bd79a126869b04c42a7acc19a6c86211f891d455 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:09:04 +0000 Subject: [PATCH 6/6] Update README.md --- README.md | 78 +++++++++++++++---------------------------------------- 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 03759aa..2ed55c6 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ All methods return numbers that can be converted to a nicely-formatted, human-re - `Profitable.arr`: Annual Recurring Revenue (ARR), calculated as current `mrr * 12`, not trailing revenue - `Profitable.ttm`: Founder-friendly shorthand alias for `ttm_revenue` - `Profitable.ttm_revenue`: Trailing twelve-month revenue, net of refunds when `amount_refunded` is present -- `Profitable.revenue_run_rate(in_the_last: 30.days)`: Recent revenue annualized (useful for secondary TrustMRR-style revenue multiples) +- `Profitable.revenue_run_rate(in_the_last: 30.days)`: Recent revenue annualized (useful for TrustMRR-style revenue multiples) - `Profitable.all_time_revenue`: Net revenue since launch - `Profitable.revenue_in_period(in_the_last: 30.days)`: Net revenue (recurring and non-recurring) in the specified period - `Profitable.recurring_revenue_in_period(in_the_last: 30.days)`: Only recurring revenue in the specified period @@ -83,7 +83,7 @@ All methods return numbers that can be converted to a nicely-formatted, human-re - `Profitable.churned_mrr(in_the_last: 30.days)`: MRR lost due to churn in the specified period - `Profitable.average_revenue_per_customer`: Average revenue per customer (ARPC) - `Profitable.lifetime_value`: Estimated customer lifetime value (LTV) -- `Profitable.estimated_valuation(at: "3x")`: Backwards-compatible ARR-based valuation heuristic +- `Profitable.estimated_valuation(at: "3x")`: ARR-based valuation heuristic - `Profitable.estimated_arr_valuation(at: "3x")`: Explicit ARR-based valuation heuristic - `Profitable.estimated_ttm_revenue_valuation(at: "2x")`: TTM revenue-based valuation heuristic - `Profitable.estimated_revenue_run_rate_valuation(at: "2x", in_the_last: 30.days)`: Recent revenue run-rate valuation heuristic @@ -136,7 +136,7 @@ Profitable.ttm_revenue.to_readable # => "$123,456" # Founder-friendly shorthand for trailing twelve-month revenue Profitable.ttm.to_readable # => "$123,456" -# Get recent revenue annualized (useful for secondary TrustMRR-style revenue multiples) +# Get recent revenue annualized (useful for TrustMRR-style revenue multiples) Profitable.revenue_run_rate(in_the_last: 30.days).to_readable # => "$96,000" # `estimated_valuation` remains as a backwards-compatible alias of `estimated_arr_valuation` @@ -173,21 +173,21 @@ Revenue methods are net of refunds when `amount_refunded` is present on `pay_cha - `mrr_growth_rate`: This calculation compares the MRR at the start and end of the specified period. It assumes a linear growth rate over the period, which may not reflect short-term fluctuations. For more accurate results, consider using shorter periods or implementing a more sophisticated growth calculation method if needed. - `time_to_next_mrr_milestone`: This estimation is based on the current MRR and the recent growth rate. It assumes a constant growth rate, which may not reflect real-world conditions. The calculation may be inaccurate for very new businesses or those with irregular growth patterns. -## Metric Guide: TTM, Revenue, Profit, ARR, and MRR +## Metric guide: TTM, Revenue, Profit, ARR, and MRR -`profitable` now exposes both standard recurring revenue metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose. +`profitable` exposes both standard recurring revenue metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose. These metrics are related, but they are not interchangeable: | Metric | What it means | Best for | What it is **not** | | --- | --- | --- | --- | -| `MRR` | Monthly Recurring Revenue from subscriptions that are billable right now | Operating cadence, near-term momentum, tracking upgrades/downgrades | Monthly cash collected from all sources | -| `ARR` | Annual Recurring Revenue, calculated as the current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | Historical last-12-month revenue | -| `MRR * 12` | Simple annualization of the current monthly recurring base | Fast ARR approximation when the base is normalized monthly | TTM revenue or TTM profit | -| `TTM revenue` | Actual revenue collected over the last 12 months | Buyer-facing historical actuals, smoothing seasonality, sanity-checking ARR | Forward recurring run-rate | -| `TTM profit` | Actual profit over the last 12 months | Small bootstrapped SaaS exits, ROI-minded buyers, earnings-based multiples | Something `profitable` can derive from `pay` alone | +| `MRR` | Monthly Recurring Revenue from subscriptions that are billable right now | Operating cadence, near-term momentum, tracking upgrades/downgrades | It's **not** monthly cash collected from all sources | +| `ARR` | Annual Recurring Revenue, calculated as the current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | It's **not** a historical last-12-month revenue | +| `MRR * 12` | Simple annualization of the current monthly recurring base | Fast ARR approximation when the base is normalized monthly | It's **not** TTM revenue or TTM profit | +| `TTM revenue` | Actual revenue collected over the last 12 months | Buyer-facing historical actuals, smoothing seasonality, sanity-checking ARR | It's **not** a forward recurring run-rate | +| `TTM profit` | Actual profit over the last 12 months | Small bootstrapped SaaS exits, ROI-minded buyers, earnings-based multiples | It's **not** something `profitable` can derive from `pay` alone | -### The Distinction That Matters +### The distinction - `ARR` is a run-rate metric. Stripe describes it as revenue you "expect to earn in a year" and notes that `ARR = MRR × 12`. - `TTM` is a trailing metric. CFI defines it as the "most recent 12-month period" and uses it for reported actuals such as revenue and EBITDA. @@ -198,24 +198,23 @@ These metrics are related, but they are not interchangeable: In other words: -- `ARR` answers: "What is my current recurring run-rate?" +- `ARR` answers: "What is my current recurring run-rate? What do I expect to earn in a year?" - `TTM revenue` answers: "What did I actually collect over the last year?" -- `TTM profit` answers: "What did I actually keep over the last year?" -### What `profitable` Computes +### What `profitable` calculates - `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now - `Profitable.arr`: Annual Recurring Revenue (ARR), calculated from current MRR - `Profitable.ttm`: shorthand alias for `ttm_revenue` - `Profitable.ttm_revenue`: trailing 12-month revenue, net of refunds when `amount_refunded` is present - `Profitable.revenue_run_rate`: recent revenue annualized to a yearly run-rate -- `Profitable.estimated_valuation`: a backwards-compatible ARR-multiple heuristic +- `Profitable.estimated_valuation`: ARR-multiple heuristic - `Profitable.estimated_ttm_revenue_valuation`: TTM revenue heuristic - `Profitable.estimated_revenue_run_rate_valuation`: recent revenue run-rate heuristic `profitable` does **not** calculate `TTM profit`, because payroll, contractor spend, hosting, support, software tools, taxes, and owner add-backs do not live inside `pay`. -### Which Metric Matters in Which Situation? +### Which metric matters in which situation? - If you're operating the business week to week: `MRR` is usually the best pulse metric. - If you want to understand your current subscription run-rate: `ARR` is the right metric. @@ -223,18 +222,10 @@ In other words: - If your business has meaningful one-time revenue, services, setup fees, or seasonal swings: `TTM revenue` matters more than `ARR`. - If you are speaking to serious SaaS buyers about revenue quality: pair `ARR` with churn, growth, concentration, and margins. -### Source Hierarchy - -This README treats `Acquire.com` as the primary valuation reference for smaller SaaS exits. - -`TrustMRR` is still useful, but as a secondary reference for how very small SaaS listings are packaged, normalized, and displayed in-market. - -### What Current Market Sources Say +### What the market says These are short excerpts from current market and finance sources, followed by why they matter for `profitable`. -Primary valuation reference: - - [Acquire.com Biannual Multiples Report (Jan 2026)](https://blog.acquire.com/acquire-com-biannual-acquisition-multiples-report-jan-2026/): "anchor valuation on profit" Acquire says the January 2026 report is focused "entirely on profit multiples," which is highly relevant for smaller bootstrapped SaaS exits. In the same report, some visual breakdowns segment businesses by `TTM revenue` bands, so it is important not to assume one bare `TTM` label means the same thing everywhere. @@ -263,8 +254,6 @@ Primary valuation reference: - [Software Equity Group, 3Q25 SaaS M&A](https://softwareequity.com/blog/saas-ma-deal-volume-and-valuations): "5.4x" SEG reported average SaaS M&A valuations of `5.4x` revenue in 3Q25, which is useful context for larger, more institutional software transactions. -Secondary listing-practice reference: - - [TrustMRR live listing example](https://trustmrr.com/startup/appalchemy): "$164,819 TTM revenue" Live marketplaces increasingly show `TTM revenue`, `TTM profit`, and `ARR` side by side, which matches how buyers actually compare deals. @@ -277,7 +266,7 @@ Secondary listing-practice reference: - [TrustMRR FAQ](https://trustmrr.com/faq): "profit margin for the last 30 days" TrustMRR asks sellers to provide profit margin separately when listing for sale, which reinforces that profit-based heuristics need cost inputs outside the payment provider. -### How to Use These Metrics Responsibly +### How to use these metrics responsibly - `estimated_valuation` is intentionally simple. It is kept as a backwards-compatible ARR heuristic. Prefer `estimated_arr_valuation` in new code when you want the denominator to be explicit. - Do not compare an ARR multiple and a TTM profit multiple as if they were the same kind of number. They are based on different denominators. @@ -285,7 +274,7 @@ Secondary listing-practice reference: - If two businesses both have `$300k ARR`, the one with lower churn, better margins, lower concentration, and cleaner growth usually deserves the higher multiple. - If two businesses both have `$300k TTM revenue`, the one with stronger profit and more recurring revenue usually deserves the higher price. -### Typical Multiples by SaaS Type and Size +### Typical multiples by SaaS type and size These are rough, source-backed heuristics. They are not interchangeable. @@ -294,7 +283,7 @@ These are rough, source-backed heuristics. They are not interchangeable. | Smaller profitable SaaS on Acquire.com (2024-2025 confirmed transactions) | `TTM profit` | `3.9x` median | [Acquire.com Jan 2026 report](https://blog.acquire.com/acquire-com-biannual-acquisition-multiples-report-jan-2026/) | | Micro-SaaS under `$100k` TTM revenue | `TTM profit` | `3.55x` average | [Acquire.com webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/) | | Micro-SaaS with `$100k-$1M` TTM revenue | `TTM profit` | `4.35x` average | [Acquire.com webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/) | -| Secondary TrustMRR marketplace listings | `Annualized last 30d revenue` | often roughly `0.6x-5.5x` ask multiples | [TrustMRR homepage snapshot](https://trustmrr.com/) | +| TrustMRR marketplace listings | `Annualized last 30d revenue` | often roughly `0.6x-5.5x` ask multiples | [TrustMRR homepage snapshot](https://trustmrr.com/) | | Mid-6-figure ARR SaaS | `TTM revenue` | `2x-4x` revenue | [Acquire.com founder-driven acquisition recap](https://blog.acquire.com/how-founders-can-drive-their-own-acquisition-process-webinar-recap/) | | Older Acquire.com SaaS baseline | `TTM revenue` or `TTM profit` | `2-3x revenue` or `5x profit` | [Acquire.com 7-8 figures webinar recap](https://blog.acquire.com/how-to-sell-your-company-playbook-webinar/) | | Strong recurring SaaS with high growth and retention | `ARR` | `5x-15x ARR` | [Acquire.com SaaS valuation multiples guide](https://blog.acquire.com/saas-valuation-multiples/) | @@ -306,7 +295,7 @@ How to read this table: - TrustMRR listing multiples are a secondary comparison set: they are based on recent revenue run-rate, specifically `last 30 days revenue × 12`. - Higher-quality SaaS with real scale, low churn, and strong growth is more likely to be discussed in `ARR` terms. -### Rough Valuation Formulas from `profitable` +### Rough valuation formulas from `profitable` You can only multiply a metric by a multiple if the denominator matches. @@ -344,7 +333,7 @@ Use this when: #### 2b. Recent revenue run-rate multiple -This is the closest match to secondary TrustMRR-style marketplace multiples: +This is the closest match to TrustMRR-style marketplace multiples: ```ruby # Default: annualized last-30-days revenue @@ -376,31 +365,6 @@ Use this when: - the buyer is focused on ROI and cash flow, - or you're comparing yourself to Acquire.com-style marketplace comps. -#### A Practical Rule - -If you are unsure which denominator the buyer is using, ask explicitly: - -- "Are you thinking in TTM profit, TTM revenue, or ARR?" - -That one question avoids a huge amount of confusion. - -### RailsFast-Based Businesses - -There is no special published "RailsFast multiple." - -Inference from the sources above: a RailsFast-based SaaS should generally be valued like any other SaaS with similar: - -- TTM revenue -- TTM profit -- ARR / MRR quality -- churn and retention -- growth rate -- customer concentration -- margins -- transferability and documentation - -The stack is usually secondary to business quality. What the stack can do indirectly is improve margins, speed of execution, and transferability, which can help the business earn a better multiple over time. - ## Development After checking out the repo, install dependencies: