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
+TTM revenue
+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