diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..49d932d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,9 +36,22 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8521302..78e5c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # `profitable` +## [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 +- 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 - Add monthly summary (12mo) and daily summary (30d) tables to dashboard - Add `period_data` method for efficient batch computation of period metrics @@ -32,4 +45,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..2ed55c6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,24 @@ 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`. + +> [!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. @@ -52,25 +70,31 @@ 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`: 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 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")`: 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 +128,24 @@ 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" + +# 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" -# 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 recent revenue annualized (useful for TrustMRR-style revenue multiples) +Profitable.revenue_run_rate(in_the_last: 30.days).to_readable # => "$96,000" + +# `estimated_valuation` remains as a backwards-compatible alias of `estimated_arr_valuation` 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 +166,205 @@ 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` exposes both standard recurring revenue metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose. + +These metrics are related, but they are not interchangeable: + +| Metric | What it means | Best for | What it is **not** | +| --- | --- | --- | --- | +| `MRR` | Monthly Recurring Revenue from subscriptions that are billable right now | Operating cadence, near-term momentum, tracking upgrades/downgrades | It's **not** monthly cash collected from all sources | +| `ARR` | Annual Recurring Revenue, calculated as the current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | It's **not** a historical last-12-month revenue | +| `MRR * 12` | Simple annualization of the current monthly recurring base | Fast ARR approximation when the base is normalized monthly | It's **not** TTM revenue or TTM profit | +| `TTM revenue` | Actual revenue collected over the last 12 months | Buyer-facing historical actuals, smoothing seasonality, sanity-checking ARR | It's **not** a forward recurring run-rate | +| `TTM profit` | Actual profit over the last 12 months | Small bootstrapped SaaS exits, ROI-minded buyers, earnings-based multiples | It's **not** something `profitable` can derive from `pay` alone | + +### The distinction + +- `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 `profitable`, the shorthand method `ttm` is defined to mean `ttm_revenue` because the gem does not yet model costs or profit. + +In other words: + +- `ARR` answers: "What is my current recurring run-rate? What do I expect to earn in a year?" +- `TTM revenue` answers: "What did I actually collect over the last year?" + +### What `profitable` calculates + +- `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now +- `Profitable.arr`: Annual Recurring Revenue (ARR), calculated from current MRR +- `Profitable.ttm`: shorthand alias for `ttm_revenue` +- `Profitable.ttm_revenue`: trailing 12-month revenue, net of refunds when `amount_refunded` is present +- `Profitable.revenue_run_rate`: recent revenue annualized to a yearly run-rate +- `Profitable.estimated_valuation`: 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. + +### What the market says + +These are short excerpts from current market and finance sources, followed by why they matter for `profitable`. + +- [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. + +- [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/) | +| 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 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. + ## Development After checking out the repo, install dependencies: diff --git a/app/controllers/profitable/dashboard_controller.rb b/app/controllers/profitable/dashboard_controller.rb index 38d6efd..77dea5a 100644 --- a/app/controllers/profitable/dashboard_controller.rb +++ b/app/controllers/profitable/dashboard_controller.rb @@ -4,6 +4,7 @@ def index @mrr = Profitable.mrr @mrr_growth_rate = Profitable.mrr_growth_rate @total_customers = Profitable.total_customers + @ttm_revenue = Profitable.ttm_revenue @all_time_revenue = Profitable.all_time_revenue @estimated_valuation = Profitable.estimated_valuation @average_revenue_per_customer = Profitable.average_revenue_per_customer diff --git a/app/views/profitable/dashboard/index.html.erb b/app/views/profitable/dashboard/index.html.erb index 65feff1..6b5c55d 100644 --- a/app/views/profitable/dashboard/index.html.erb +++ b/app/views/profitable/dashboard/index.html.erb @@ -53,6 +53,10 @@

<%= @mrr.to_readable %>

MRR

+
+

<%= @ttm_revenue.to_readable %>

+

TTM revenue

+

<%= @estimated_valuation.to_readable %>

Valuation at 3x ARR

diff --git a/lib/profitable.rb b/lib/profitable.rb index ed924e6..95f3d1c 100644 --- a/lib/profitable.rb +++ b/lib/profitable.rb @@ -12,520 +12,4 @@ require "active_support/core_ext/numeric/conversions" require "action_view" -module Profitable - # Subscription status constants (at module level so MrrCalculator can reference them) - EXCLUDED_STATUSES = ['trialing', 'paused'].freeze - CHURNED_STATUSES = ['canceled', 'ended'].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 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 estimated_valuation(multiplier = nil, at: nil, multiple: nil) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation(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 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 - - def calculate_all_time_revenue - paid_charges.sum(:amount) - end - - def calculate_arr - (mrr.to_f * 12).round - end - - def calculate_estimated_valuation(multiplier = 3) - multiplier = parse_multiplier(multiplier) - (calculate_arr * 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) - paid_charges.where(created_at: period.ago..Time.current).sum(:amount) - 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) - 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 - Pay::Customer.joins(:charges) - .merge(paid_charges) - .distinct - .count - end - - def calculate_total_subscribers - Pay::Customer.joins(:subscriptions).distinct.count - end - - def calculate_active_subscribers - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { status: 'active' }) - .distinct - .count - 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 - end - - def calculate_new_customers(period) - actual_customers.where(created_at: period.ago..Time.current).count - 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: - # - Created 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) - 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) - ).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 = actual_customers.where(created_at: period_start..period_end).count - 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) - - # 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) - .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 - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) - - 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( - Pay::Subscription - .where(status: 'active') - .where(created_at: 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 = Pay::Subscription - .where('pay_subscriptions.created_at < ?', overall_end) - .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) - - # 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| s.created_at >= month_start && s.created_at <= 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 { |_, created_at, ends_at| created_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 - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) - - 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) - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { created_at: period_start..period_end }) - .where.not(pay_subscriptions: { status: EXCLUDED_STATUSES }) - .distinct - .count - 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( - Pay::Subscription - .where(status: 'active') - .where(created_at: 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 = 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) - .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/lib/profitable/mrr_calculator.rb b/lib/profitable/mrr_calculator.rb index a35888c..af161bf 100644 --- a/lib/profitable/mrr_calculator.rb +++ b/lib/profitable/mrr_calculator.rb @@ -8,9 +8,28 @@ 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 - .active - .where.not(status: Profitable::EXCLUDED_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) .includes(:customer) .select('pay_subscriptions.*, pay_customers.processor as customer_processor') .joins(:customer) @@ -50,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 7af410b..dd60b30 100644 --- a/lib/profitable/processors/braintree_processor.rb +++ b/lib/profitable/processors/braintree_processor.rb @@ -8,11 +8,13 @@ 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 - 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..6f921cb 100644 --- a/lib/profitable/processors/paddle_billing_processor.rb +++ b/lib/profitable/processors/paddle_billing_processor.rb @@ -18,11 +18,12 @@ 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') - 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..fe16d4c 100644 --- a/lib/profitable/processors/paddle_classic_processor.rb +++ b/lib/profitable/processors/paddle_classic_processor.rb @@ -8,11 +8,12 @@ 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 - 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/stripe_processor.rb b/lib/profitable/processors/stripe_processor.rb index 443eae1..cbb8433 100644 --- a/lib/profitable/processors/stripe_processor.rb +++ b/lib/profitable/processors/stripe_processor.rb @@ -18,6 +18,10 @@ def calculate_mrr 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'] next if amount.nil? diff --git a/test/mrr_calculator_test.rb b/test/mrr_calculator_test.rb index 3c30f7b..b1ca51a 100644 --- a/test/mrr_calculator_test.rb +++ b/test/mrr_calculator_test.rb @@ -84,6 +84,30 @@ 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, + 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 +119,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 +164,61 @@ 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_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, + 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( @@ -159,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, @@ -169,6 +295,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 +316,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 +337,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..b38b870 100644 --- a/test/profitable_test.rb +++ b/test/profitable_test.rb @@ -34,6 +34,70 @@ 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_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, + unit_amount: 9900, + interval: "month", + status: "past_due" + ) + + 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, + 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 +189,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 +240,89 @@ 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_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) + + 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 +343,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 +370,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 +490,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 +583,53 @@ 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_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( @@ -334,21 +651,85 @@ 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 + + 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 # ============================================================================ 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 + + 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: 30.days).to_i + 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 +752,48 @@ 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 + + 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 # ============================================================================ @@ -397,6 +820,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 # ============================================================================ @@ -434,6 +879,60 @@ 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 + + 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 # ============================================================================ @@ -455,6 +954,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 # ============================================================================ @@ -605,6 +1124,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 +1217,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 +1322,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 +1461,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..11898f7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -118,537 +118,23 @@ 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) - EXCLUDED_STATUSES = ['trialing', 'paused'].freeze - CHURNED_STATUSES = ['canceled', 'ended'].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 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 estimated_valuation(multiplier = nil, at: nil, multiple: nil) - actual_multiplier = multiplier || at || multiple || 3 - NumericResult.new(calculate_estimated_valuation(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 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 calculate_all_time_revenue - paid_charges.sum(:amount) - end - - def calculate_arr - (mrr.to_f * 12).round - end - - def calculate_estimated_valuation(multiplier = 3) - multiplier = parse_multiplier(multiplier) - (calculate_arr * 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) - paid_charges.where(created_at: period.ago..Time.current).sum(:amount) - 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) - 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 - Pay::Customer.joins(:charges) - .merge(paid_charges) - .distinct - .count - end - - def calculate_total_subscribers - Pay::Customer.joins(:subscriptions).distinct.count - end - - def calculate_active_subscribers - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { status: 'active' }) - .distinct - .count - 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 - end - - def calculate_new_customers(period) - actual_customers.where(created_at: period.ago..Time.current).count - 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: - # - Created 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) - 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) - ).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 = actual_customers.where(created_at: period_start..period_end).count - 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) - - # 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) - .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 - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) - - 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( - Pay::Subscription - .where(status: 'active') - .where(created_at: 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 = Pay::Subscription - .where('pay_subscriptions.created_at < ?', overall_end) - .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) - - # 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| s.created_at >= month_start && s.created_at <= 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 { |_, created_at, ends_at| created_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 - .where(created_at: overall_start..overall_end) - .where.not(status: EXCLUDED_STATUSES) - .pluck(:customer_id, :created_at) - - 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) - Pay::Customer.joins(:subscriptions) - .where(pay_subscriptions: { created_at: period_start..period_end }) - .where.not(pay_subscriptions: { status: EXCLUDED_STATUSES }) - .distinct - .count - 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( - Pay::Subscription - .where(status: 'active') - .where(created_at: 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 = 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) - .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 - 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)}", @@ -658,7 +144,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 } } } @@ -673,7 +160,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] } } } @@ -807,12 +295,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 +312,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,