├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── controllers │ └── profitable │ │ ├── base_controller.rb │ │ └── dashboard_controller.rb └── views │ ├── layouts │ └── profitable │ │ └── application.html.erb │ └── profitable │ └── dashboard │ └── index.html.erb ├── bin ├── console └── setup ├── config └── routes.rb ├── lib ├── profitable.rb └── profitable │ ├── engine.rb │ ├── error.rb │ ├── mrr_calculator.rb │ ├── numeric_result.rb │ ├── processors │ ├── base.rb │ ├── braintree_processor.rb │ ├── paddle_billing_processor.rb │ ├── paddle_classic_processor.rb │ └── stripe_processor.rb │ └── version.rb ├── profitable.gemspec ├── profitable.webp └── sig └── profitable.rbs /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /dist 10 | *.gem -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `profitable` 2 | 3 | ## [0.2.3] - 2024-09-01 4 | 5 | - Fix the `time_to_next_mrr_milestone` estimation and make it accurate to the day 6 | 7 | ## [0.2.2] - 2024-09-01 8 | 9 | - Improve MRR calculations with prorated churned and new MRR (hopefully fixes bad churned MRR calculations) 10 | - Only consider paid charges for all revenue calculations (hopefully fixes bad ARPC calculations) 11 | - Add `multiple:` parameter as another option for `estimated_valuation` (same as `at:`, just syntactic sugar) 12 | 13 | ## [0.2.1] - 2024-08-31 14 | 15 | - Add syntactic sugar for `estimated_valuation(at: "3x")` 16 | - Now `estimated_valuation` also supports `Numeric`-only inputs like `estimated_valuation(3)`, so that @pretzelhands can avoid writing 3 extra characters and we embrace actual syntactic sugar instead of "syntactic saccharine" (sic.) 17 | 18 | ## [0.2.0] - 2024-08-31 19 | 20 | - Initial production ready release 21 | 22 | ## [0.1.0] - 2024-08-29 23 | 24 | - Initial test release (not production ready) -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in profitable.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Javi R 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💸 `profitable` - SaaS metrics for your Rails app 2 | 3 | [![Gem Version](https://badge.fury.io/rb/profitable.svg)](https://badge.fury.io/rb/profitable) 4 | 5 | Calculate the MRR, ARR, churn, LTV, ARPU, total revenue & estimated valuation of your `pay`-powered Rails SaaS app, and display them in a simple dashboard. 6 | 7 | ![Profitable gem main dashboard](profitable.webp) 8 | 9 | ## Why 10 | 11 | [`pay`](https://github.com/pay-rails/pay) is the easiest way of handling payments in your Rails application. Think of `profitable` as the complement to `pay` that calculates business SaaS metrics like MRR, ARR, churn, total revenue & estimated valuation directly within your Rails application. 12 | 13 | Usually, you would look into your Stripe Dashboard or query the Stripe API to know your MRR / ARR / churn – but when you're using `pay`, you already have that data available and auto synced to your own database. So we can leverage it to make handy, composable ActiveRecord queries that you can reuse in any part of your Rails app (dashboards, internal pages, reports, status messages, etc.) 14 | 15 | Think doing something like: `"Your app is currently at $#{Profitable.mrr} MRR – Estimated to be worth $#{Profitable.valuation_estimate("3x")} at a 3x valuation"` 16 | 17 | ## Installation 18 | 19 | Add this line to your application's Gemfile: 20 | ```ruby 21 | gem 'profitable' 22 | ``` 23 | 24 | Then run `bundle install`. 25 | 26 | 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. 27 | 28 | ## Mount the `/profitable` dashboard 29 | 30 | `profitable` also provides a simple dashboard to see your main business metrics. 31 | 32 | In your `config/routes.rb` file, mount the `profitable` engine: 33 | ```ruby 34 | mount Profitable::Engine => '/profitable' 35 | ``` 36 | 37 | It's a good idea to make sure you're adding some sort of authentication to the `/profitable` route to avoid exposing sensitive information: 38 | ```ruby 39 | authenticate :user, ->(user) { user.admin? } do 40 | mount Profitable::Engine => '/profitable' 41 | end 42 | ``` 43 | 44 | You can now navigate to `/profitable` to see your app's business metrics like MRR, ARR, churn, etc. 45 | 46 | ## Main `Profitable` methods 47 | 48 | All methods return numbers that can be converted to a nicely-formatted, human-readable string using the `to_readable` method. 49 | 50 | ### Revenue metrics 51 | 52 | - `Profitable.mrr`: Monthly Recurring Revenue (MRR) 53 | - `Profitable.arr`: Annual Recurring Revenue (ARR) 54 | - `Profitable.all_time_revenue`: Total revenue since launch 55 | - `Profitable.revenue_in_period(in_the_last: 30.days)`: Total revenue (recurring and non-recurring) in the specified period 56 | - `Profitable.recurring_revenue_in_period(in_the_last: 30.days)`: Only recurring revenue in the specified period 57 | - `Profitable.recurring_revenue_percentage(in_the_last: 30.days)`: Percentage of revenue that is recurring in the specified period 58 | - `Profitable.new_mrr(in_the_last: 30.days)`: New MRR added in the specified period 59 | - `Profitable.churned_mrr(in_the_last: 30.days)`: MRR lost due to churn in the specified period 60 | - `Profitable.average_revenue_per_customer`: Average revenue per customer (ARPC) 61 | - `Profitable.lifetime_value`: Estimated customer lifetime value (LTV) 62 | - `Profitable.estimated_valuation(at: "3x")`: Estimated company valuation based on ARR 63 | 64 | ### Customer metrics 65 | 66 | - `Profitable.total_customers`: Total number of customers who have ever made a purchase or had a subscription (current and past) 67 | - `Profitable.total_subscribers`: Total number of customers who have ever had a subscription (active or not) 68 | - `Profitable.active_subscribers`: Number of customers with currently active subscriptions 69 | - `Profitable.new_customers(in_the_last: 30.days)`: Number of new customers added in the specified period 70 | - `Profitable.new_subscribers(in_the_last: 30.days)`: Number of new subscribers added in the specified period 71 | - `Profitable.churned_customers(in_the_last: 30.days)`: Number of customers who churned in the specified period 72 | 73 | ### Other metrics 74 | 75 | - `Profitable.churn(in_the_last: 30.days)`: Churn rate for the specified period 76 | - `Profitable.mrr_growth_rate(in_the_last: 30.days)`: MRR growth rate for the specified period 77 | - `Profitable.time_to_next_mrr_milestone`: Estimated time to reach the next MRR milestone 78 | 79 | ### Growth metrics 80 | 81 | - `Profitable.mrr_growth(in_the_last: 30.days)`: Calculates the absolute MRR growth over the specified period 82 | - `Profitable.mrr_growth_rate(in_the_last: 30.days)`: Calculates the MRR growth rate (as a percentage) over the specified period 83 | 84 | ### Milestone metrics 85 | 86 | - `Profitable.time_to_next_mrr_milestone`: Estimates the time to reach the next MRR milestone 87 | 88 | ### Usage examples 89 | 90 | ```ruby 91 | # Get the current MRR 92 | Profitable.mrr.to_readable # => "$1,234" 93 | 94 | # Get the number of new customers in the last 60 days 95 | Profitable.new_customers(in_the_last: 60.days).to_readable # => "42" 96 | 97 | # Get the churn rate for the last quarter 98 | Profitable.churn(in_the_last: 3.months).to_readable # => "12%" 99 | 100 | # You can specify the precision of the output number (no decimals by default) 101 | Profitable.new_mrr(in_the_last: 24.hours).to_readable(2) # => "$123.45" 102 | 103 | # Get the estimated valuation at 5x ARR (defaults to 3x if no multiple is specified) 104 | Profitable.estimated_valuation(multiple: 5).to_readable # => "$500,000" 105 | 106 | # 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 107 | Profitable.estimated_valuation(at: "4.5x").to_readable # => "$450,000" 108 | 109 | # Get the time to next MRR milestone 110 | Profitable.time_to_next_mrr_milestone.to_readable # => "26 days left to $10,000 MRR" 111 | ``` 112 | 113 | All time-based methods default to a 30-day period if no time range is specified. 114 | 115 | ### Numeric values and readable format 116 | 117 | Numeric values are returned in the same currency as your `pay` configuration. The `to_readable` method returns a human-readable format: 118 | 119 | - Currency values are prefixed with "$" and formatted as currency. 120 | - Percentage values are suffixed with "%" and formatted as percentages. 121 | - Integer values are formatted with thousands separators but without currency symbols. 122 | 123 | For more precise calculations, you can access the raw numeric value: 124 | ```ruby 125 | # Returns the raw MRR integer value in cents (123456 equals $1.234,56) 126 | Profitable.mrr # => 123456 127 | ``` 128 | 129 | ### Notes on specific metrics 130 | 131 | - `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. 132 | - `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. 133 | 134 | ## Development 135 | 136 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 137 | 138 | To install this gem onto your local machine, run `bundle exec rake install`. 139 | 140 | ## TODO 141 | - [ ] Calculate split by plan / add support for multiple plans (churn by plan, MRR by plan, etc) – not just aggregated 142 | - [ ] Calculate MRR expansion (plan upgrades), contraction (plan downgrades), etc. like Stripe does 143 | - [ ] Add active customers (not just total customers) 144 | - [ ] Add % of change over last period (this period vs last period) 145 | - [ ] Calculate total period revenue vs period recurring revenue (started, but not sure if accurate) 146 | - [ ] Add revenue last month to dashboard (not just past 30d, like previous month) 147 | - [ ] Support other currencies other than USD (convert currencies) 148 | - [ ] Make sure other payment processors other than Stripe work as intended (Paddle, Braintree, etc. – I've never used them) 149 | - [ ] Add a way to input monthly costs (maybe via config file?) so that we can calculate a profit margin % 150 | - [ ] Allow dashboard configuration via config file (which metrics to show, etc.) 151 | - [ ] Return a JSON in the dashboard endpoint with main metrics (for monitoring / downstream consumption) 152 | 153 | ## Contributing 154 | 155 | Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/profitable. Our code of conduct is: just be nice and make your mom proud of what you do and post online. 156 | 157 | ## License 158 | 159 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 160 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | task default: %i[] 5 | -------------------------------------------------------------------------------- /app/controllers/profitable/base_controller.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | class BaseController < ApplicationController 3 | layout 'profitable/application' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/controllers/profitable/dashboard_controller.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | class DashboardController < BaseController 3 | def index 4 | end 5 | 6 | private 7 | 8 | def test 9 | end 10 | 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/layouts/profitable/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 💸 <%= Rails.application.class.module_parent_name %> SaaS Dashboard 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | 9 | 10 | <%= yield %> 11 | 12 | -------------------------------------------------------------------------------- /app/views/profitable/dashboard/index.html.erb: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 |

💸 <%= Rails.application.class.module_parent_name %>

35 | <% if Profitable.mrr_growth_rate > 0 %> 36 |

<%= Profitable.time_to_next_mrr_milestone %>

37 | <% end %> 38 |
39 | 40 |
41 | 42 |
43 |
44 |

<%= Profitable.total_customers.to_readable %>

45 |

total customers

46 |
47 |
48 |

<%= Profitable.mrr.to_readable %>

49 |

MRR

50 |
51 |
52 |

<%= Profitable.estimated_valuation.to_readable %>

53 |

Valuation at 3x ARR

54 |
55 |
56 |

<%= Profitable.mrr_growth_rate.to_readable %>

57 |

MRR growth rate

58 |
59 |
60 |

<%= Profitable.average_revenue_per_customer.to_readable %>

61 |

ARPC

62 |
63 |
64 |

<%= Profitable.lifetime_value.to_readable %>

65 |

LTV

66 |
67 |
68 |

<%= Profitable.all_time_revenue.to_readable %>

69 |

All-time revenue

70 |
71 |
72 | 73 | <% [24.hours, 7.days, 30.days].each do |period| %> 74 | <% period_short = period.inspect.gsub("days", "d").gsub("hours", "h").gsub(" ", "") %> 75 | 76 |

Last <%= period.inspect %>

77 | 78 |
79 |
80 |

<%= Profitable.new_customers(in_the_last: period).to_readable %>

81 |

new customers (<%= period_short %>)

82 |
83 |
84 |

<%= Profitable.churned_customers(in_the_last: period).to_readable %>

85 |

churned customers (<%= period_short %>)

86 |
87 |
88 |

<%= Profitable.churn(in_the_last: period).to_readable %>

89 |

churn (<%= period_short %>)

90 |
91 | 92 |
93 |

<%= Profitable.new_mrr(in_the_last: period).to_readable %>

94 |

new MRR (<%= period_short %>)

95 |
96 |
97 |

<%= Profitable.churned_mrr(in_the_last: period).to_readable %>

98 |

churned MRR (<%= period_short %>)

99 |
100 |
101 |

<%= Profitable.mrr_growth(in_the_last: period).to_readable %>

102 |

MRR growth (<%= period_short %>)

103 |
104 | 105 |
106 |

<%= Profitable.revenue_in_period(in_the_last: period).to_readable %>

107 |

total revenue (<%= period_short %>)

108 |
109 | 110 |
111 | <% end %> 112 | 113 |
114 | 115 | 118 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "profitable" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Profitable::Engine.routes.draw do 2 | root to: "dashboard#index" 3 | end 4 | -------------------------------------------------------------------------------- /lib/profitable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "profitable/version" 4 | require_relative "profitable/error" 5 | require_relative "profitable/engine" 6 | 7 | require_relative "profitable/mrr_calculator" 8 | require_relative "profitable/numeric_result" 9 | 10 | require "pay" 11 | require "active_support/core_ext/numeric/conversions" 12 | require "action_view" 13 | 14 | module Profitable 15 | class << self 16 | include ActionView::Helpers::NumberHelper 17 | 18 | DEFAULT_PERIOD = 30.days 19 | 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] 20 | 21 | def mrr 22 | NumericResult.new(MrrCalculator.calculate) 23 | end 24 | 25 | def arr 26 | NumericResult.new(calculate_arr) 27 | end 28 | 29 | def churn(in_the_last: DEFAULT_PERIOD) 30 | NumericResult.new(calculate_churn(in_the_last), :percentage) 31 | end 32 | 33 | def all_time_revenue 34 | NumericResult.new(calculate_all_time_revenue) 35 | end 36 | 37 | def revenue_in_period(in_the_last: DEFAULT_PERIOD) 38 | NumericResult.new(calculate_revenue_in_period(in_the_last)) 39 | end 40 | 41 | def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD) 42 | NumericResult.new(calculate_recurring_revenue_in_period(in_the_last)) 43 | end 44 | 45 | def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) 46 | NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) 47 | end 48 | 49 | def estimated_valuation(multiplier = nil, at: nil, multiple: nil) 50 | actual_multiplier = multiplier || at || multiple || 3 51 | NumericResult.new(calculate_estimated_valuation(actual_multiplier)) 52 | end 53 | 54 | def total_customers 55 | NumericResult.new(calculate_total_customers, :integer) 56 | end 57 | 58 | def total_subscribers 59 | NumericResult.new(calculate_total_subscribers, :integer) 60 | end 61 | 62 | def active_subscribers 63 | NumericResult.new(calculate_active_subscribers, :integer) 64 | end 65 | 66 | def new_customers(in_the_last: DEFAULT_PERIOD) 67 | NumericResult.new(calculate_new_customers(in_the_last), :integer) 68 | end 69 | 70 | def new_subscribers(in_the_last: DEFAULT_PERIOD) 71 | NumericResult.new(calculate_new_subscribers(in_the_last), :integer) 72 | end 73 | 74 | def churned_customers(in_the_last: DEFAULT_PERIOD) 75 | NumericResult.new(calculate_churned_customers(in_the_last), :integer) 76 | end 77 | 78 | def new_mrr(in_the_last: DEFAULT_PERIOD) 79 | NumericResult.new(calculate_new_mrr(in_the_last)) 80 | end 81 | 82 | def churned_mrr(in_the_last: DEFAULT_PERIOD) 83 | NumericResult.new(calculate_churned_mrr(in_the_last)) 84 | end 85 | 86 | def average_revenue_per_customer 87 | NumericResult.new(calculate_average_revenue_per_customer) 88 | end 89 | 90 | def lifetime_value 91 | NumericResult.new(calculate_lifetime_value) 92 | end 93 | 94 | def mrr_growth(in_the_last: DEFAULT_PERIOD) 95 | NumericResult.new(calculate_mrr_growth(in_the_last)) 96 | end 97 | 98 | def mrr_growth_rate(in_the_last: DEFAULT_PERIOD) 99 | NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage) 100 | end 101 | 102 | def time_to_next_mrr_milestone 103 | current_mrr = (mrr.to_i)/100 104 | next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr } 105 | return "Congratulations! You've reached the highest milestone." unless next_milestone 106 | 107 | monthly_growth_rate = calculate_mrr_growth_rate / 100 108 | return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0 109 | 110 | # Convert monthly growth rate to daily growth rate 111 | daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1 112 | 113 | # Calculate the number of days to reach the next milestone 114 | days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil 115 | 116 | target_date = Time.current + days_to_milestone.days 117 | 118 | "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})" 119 | end 120 | 121 | private 122 | 123 | def paid_charges 124 | Pay::Charge.where("(pay_charges.data ->> 'paid' IS NULL OR pay_charges.data ->> 'paid' != ?) AND pay_charges.amount > 0", 'false') 125 | .where("pay_charges.data ->> 'status' = ? OR pay_charges.data ->> 'status' IS NULL", 'succeeded') 126 | end 127 | 128 | def calculate_all_time_revenue 129 | paid_charges.sum(:amount) 130 | end 131 | 132 | def calculate_arr 133 | (mrr.to_f * 12).round 134 | end 135 | 136 | def calculate_estimated_valuation(multiplier = 3) 137 | multiplier = parse_multiplier(multiplier) 138 | (calculate_arr * multiplier).round 139 | end 140 | 141 | def parse_multiplier(input) 142 | case input 143 | when Numeric 144 | input.to_f 145 | when String 146 | if input.end_with?('x') 147 | input.chomp('x').to_f 148 | else 149 | input.to_f 150 | end 151 | else 152 | 3.0 # Default multiplier if input is invalid 153 | end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range 154 | end 155 | 156 | def calculate_churn(period = DEFAULT_PERIOD) 157 | start_date = period.ago 158 | total_subscribers_start = Pay::Subscription.active.where('created_at < ?', start_date).distinct.count('customer_id') 159 | churned = calculate_churned_customers(period) 160 | return 0 if total_subscribers_start == 0 161 | (churned.to_f / total_subscribers_start * 100).round(2) 162 | end 163 | 164 | def churned_subscriptions(period = DEFAULT_PERIOD) 165 | Pay::Subscription 166 | .includes(:customer) 167 | .select('pay_subscriptions.*, pay_customers.processor as customer_processor') 168 | .joins(:customer) 169 | .where(status: ['canceled', 'ended']) 170 | .where(ends_at: period.ago..Time.current) 171 | end 172 | 173 | def calculate_churned_customers(period = DEFAULT_PERIOD) 174 | churned_subscriptions(period).distinct.count('customer_id') 175 | end 176 | 177 | def calculate_churned_mrr(period = DEFAULT_PERIOD) 178 | start_date = period.ago 179 | end_date = Time.current 180 | 181 | Pay::Subscription 182 | .includes(:customer) 183 | .select('pay_subscriptions.*, pay_customers.processor as customer_processor') 184 | .joins(:customer) 185 | .where(status: ['canceled', 'ended']) 186 | .where('pay_subscriptions.updated_at BETWEEN ? AND ?', start_date, end_date) 187 | .sum do |subscription| 188 | if subscription.ends_at && subscription.ends_at > end_date 189 | # Subscription ends in the future, don't count it as churned yet 190 | 0 191 | else 192 | # Calculate prorated MRR if the subscription ended within the period 193 | end_date = [subscription.ends_at, end_date].compact.min 194 | days_in_period = (end_date - start_date).to_i 195 | total_days = (subscription.current_period_end - subscription.current_period_start).to_i 196 | prorated_days = [days_in_period, total_days].min 197 | 198 | mrr = MrrCalculator.process_subscription(subscription) 199 | (mrr.to_f * prorated_days / total_days).round 200 | end 201 | end 202 | end 203 | 204 | def calculate_new_mrr(period = DEFAULT_PERIOD) 205 | start_date = period.ago 206 | end_date = Time.current 207 | 208 | Pay::Subscription 209 | .active 210 | .includes(:customer) 211 | .select('pay_subscriptions.*, pay_customers.processor as customer_processor') 212 | .joins(:customer) 213 | .where(created_at: start_date..end_date) 214 | .where.not(status: ['trialing', 'paused']) 215 | .sum do |subscription| 216 | mrr = MrrCalculator.process_subscription(subscription) 217 | days_in_period = (end_date - subscription.created_at).to_i 218 | total_days = (subscription.current_period_end - subscription.current_period_start).to_i 219 | prorated_days = [days_in_period, total_days].min 220 | (mrr.to_f * prorated_days / total_days).round 221 | end 222 | end 223 | 224 | def calculate_revenue_in_period(period) 225 | paid_charges.where(created_at: period.ago..Time.current).sum(:amount) 226 | end 227 | 228 | def calculate_recurring_revenue_in_period(period) 229 | paid_charges 230 | .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') 231 | .where(created_at: period.ago..Time.current) 232 | .sum(:amount) 233 | end 234 | 235 | def calculate_recurring_revenue_percentage(period) 236 | total_revenue = calculate_revenue_in_period(period) 237 | recurring_revenue = calculate_recurring_revenue_in_period(period) 238 | 239 | return 0 if total_revenue.zero? 240 | 241 | ((recurring_revenue.to_f / total_revenue) * 100).round(2) 242 | end 243 | 244 | def calculate_total_customers 245 | Pay::Customer.joins(:charges) 246 | .merge(paid_charges) 247 | .distinct 248 | .count 249 | end 250 | 251 | def calculate_total_subscribers 252 | Pay::Customer.joins(:subscriptions).distinct.count 253 | end 254 | 255 | def calculate_active_subscribers 256 | Pay::Customer.joins(:subscriptions) 257 | .where(pay_subscriptions: { status: 'active' }) 258 | .distinct 259 | .count 260 | end 261 | 262 | def actual_customers 263 | Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id") 264 | .joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id") 265 | .where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0") 266 | .distinct 267 | end 268 | 269 | def calculate_new_customers(period) 270 | actual_customers.where(created_at: period.ago..Time.current).count 271 | end 272 | 273 | def calculate_new_subscribers(period) 274 | Pay::Customer.joins(:subscriptions) 275 | .where(created_at: period.ago..Time.current) 276 | .distinct 277 | .count 278 | end 279 | 280 | def calculate_average_revenue_per_customer 281 | paying_customers = calculate_total_customers 282 | return 0 if paying_customers.zero? 283 | (all_time_revenue.to_f / paying_customers).round 284 | end 285 | 286 | def calculate_lifetime_value 287 | return 0 if total_customers.zero? 288 | churn_rate = churn.to_f / 100 289 | return 0 if churn_rate.zero? 290 | (average_revenue_per_customer.to_f / churn_rate).round 291 | end 292 | 293 | def calculate_mrr_growth(period = DEFAULT_PERIOD) 294 | new_mrr = calculate_new_mrr(period) 295 | churned_mrr = calculate_churned_mrr(period) 296 | new_mrr - churned_mrr 297 | end 298 | 299 | def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) 300 | end_date = Time.current 301 | start_date = end_date - period 302 | 303 | start_mrr = calculate_mrr_at(start_date) 304 | end_mrr = calculate_mrr_at(end_date) 305 | 306 | return 0 if start_mrr == 0 307 | ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2) 308 | end 309 | 310 | def calculate_mrr_at(date) 311 | Pay::Subscription 312 | .active 313 | .where('pay_subscriptions.created_at <= ?', date) 314 | .where.not(status: ['trialing', 'paused']) 315 | .includes(:customer) 316 | .select('pay_subscriptions.*, pay_customers.processor as customer_processor') 317 | .joins(:customer) 318 | .sum do |subscription| 319 | MrrCalculator.process_subscription(subscription) 320 | end 321 | end 322 | 323 | end 324 | end 325 | -------------------------------------------------------------------------------- /lib/profitable/engine.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Profitable 4 | 5 | # TODO: implement config 6 | # initializer "Profitable.load_configuration" do 7 | # config_file = Rails.root.join("config", "profitable.rb") 8 | # if File.exist?(config_file) 9 | # Profitable.configure do |config| 10 | # config.instance_eval(File.read(config_file)) 11 | # end 12 | # end 13 | # end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/profitable/error.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | class Error < StandardError; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/profitable/mrr_calculator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'processors/base' 2 | require_relative 'processors/stripe_processor' 3 | require_relative 'processors/braintree_processor' 4 | require_relative 'processors/paddle_billing_processor' 5 | require_relative 'processors/paddle_classic_processor' 6 | 7 | module Profitable 8 | class MrrCalculator 9 | def self.calculate 10 | total_mrr = 0 11 | subscriptions = Pay::Subscription 12 | .active 13 | .where.not(status: ['trialing', 'paused']) 14 | .includes(:customer) 15 | .select('pay_subscriptions.*, pay_customers.processor as customer_processor') 16 | .joins(:customer) 17 | 18 | subscriptions.find_each do |subscription| 19 | mrr = process_subscription(subscription) 20 | total_mrr += mrr if mrr.is_a?(Numeric) && mrr > 0 21 | end 22 | 23 | total_mrr 24 | rescue => e 25 | Rails.logger.error("Error calculating total MRR: #{e.message}") 26 | raise Profitable::Error, "Failed to calculate MRR: #{e.message}" 27 | end 28 | 29 | def self.process_subscription(subscription) 30 | return 0 if subscription.nil? || subscription.data.nil? 31 | 32 | processor_class = processor_for(subscription.customer_processor) 33 | mrr = processor_class.new(subscription).calculate_mrr 34 | 35 | # Ensure MRR is a non-negative number 36 | mrr.is_a?(Numeric) ? [mrr, 0].max : 0 37 | rescue => e 38 | Rails.logger.error("Error calculating MRR for subscription #{subscription.id}: #{e.message}") 39 | 0 40 | end 41 | 42 | def self.processor_for(processor_name) 43 | case processor_name 44 | when 'stripe' 45 | Processors::StripeProcessor 46 | when 'braintree' 47 | Processors::BraintreeProcessor 48 | when 'paddle_billing' 49 | Processors::PaddleBillingProcessor 50 | when 'paddle_classic' 51 | Processors::PaddleClassicProcessor 52 | else 53 | Rails.logger.warn("Unknown processor: #{processor_name}") 54 | Processors::Base 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/profitable/numeric_result.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | class NumericResult < SimpleDelegator 3 | include ActionView::Helpers::NumberHelper 4 | 5 | def initialize(value, type = :currency) 6 | super(value) 7 | @type = type 8 | end 9 | 10 | def to_readable(precision = 0) 11 | case @type 12 | when :currency 13 | "$#{price_in_cents_to_string(self, precision)}" 14 | when :percentage 15 | "#{number_with_precision(self, precision: precision)}%" 16 | when :integer 17 | number_with_delimiter(self) 18 | when :string 19 | self.to_s 20 | else 21 | to_s 22 | end 23 | end 24 | 25 | private 26 | 27 | def price_in_cents_to_string(price, precision = 2) 28 | formatted_price = number_with_delimiter( 29 | number_with_precision( 30 | (price.to_f / 100), precision: precision 31 | ) 32 | ).to_s 33 | 34 | if price.zero? 35 | "0" 36 | elsif precision == 0 37 | formatted_price 38 | else 39 | formatted_price.sub(/\.?0+$/, '') 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/profitable/processors/base.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | module Processors 3 | class Base 4 | attr_reader :subscription 5 | 6 | def initialize(subscription) 7 | @subscription = subscription 8 | end 9 | 10 | def calculate_mrr 11 | 0 12 | end 13 | 14 | protected 15 | 16 | def normalize_to_monthly(amount, interval, interval_count) 17 | return 0 if amount.nil? || interval.nil? || interval_count.nil? 18 | 19 | case interval.to_s.downcase 20 | when 'day' 21 | amount * 30.0 / interval_count 22 | when 'week' 23 | amount * 4.0 / interval_count 24 | when 'month' 25 | amount / interval_count 26 | when 'year' 27 | amount / (12.0 * interval_count) 28 | else 29 | Rails.logger.warn("Unknown interval for MRR calculation: #{interval}") 30 | 0 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/profitable/processors/braintree_processor.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | module Processors 3 | class BraintreeProcessor < Base 4 | def calculate_mrr 5 | amount = subscription.data['price'] 6 | quantity = subscription.quantity || 1 7 | interval = subscription.data['billing_period_unit'] 8 | interval_count = subscription.data['billing_period_frequency'] || 1 9 | 10 | normalize_to_monthly(amount * quantity, interval, interval_count) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/profitable/processors/paddle_billing_processor.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | module Processors 3 | class PaddleBillingProcessor < Base 4 | def calculate_mrr 5 | price_data = subscription.data['items']&.first&.dig('price') 6 | return 0 if price_data.nil? 7 | 8 | amount = price_data['unit_price']['amount'] 9 | quantity = subscription.quantity || 1 10 | interval = price_data['billing_cycle']['interval'] 11 | interval_count = price_data['billing_cycle']['frequency'] 12 | 13 | normalize_to_monthly(amount * quantity, interval, interval_count) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/profitable/processors/paddle_classic_processor.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | module Processors 3 | class PaddleClassicProcessor < Base 4 | def calculate_mrr 5 | amount = subscription.data['recurring_price'] 6 | quantity = subscription.quantity || 1 7 | interval = subscription.data['recurring_interval'] 8 | interval_count = 1 # Paddle Classic doesn't have interval_count 9 | 10 | normalize_to_monthly(amount * quantity, interval, interval_count) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/profitable/processors/stripe_processor.rb: -------------------------------------------------------------------------------- 1 | module Profitable 2 | module Processors 3 | class StripeProcessor < Base 4 | def calculate_mrr 5 | subscription_items = subscription.data['subscription_items'] 6 | return 0 if subscription_items.nil? || subscription_items.empty? 7 | 8 | price_data = subscription_items[0]['price'] 9 | return 0 if price_data.nil? 10 | 11 | amount = price_data['unit_amount'] 12 | quantity = subscription.quantity || 1 13 | interval = price_data.dig('recurring', 'interval') 14 | interval_count = price_data.dig('recurring', 'interval_count') || 1 15 | 16 | normalize_to_monthly(amount * quantity, interval, interval_count) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/profitable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Profitable 4 | VERSION = "0.2.3" 5 | end 6 | -------------------------------------------------------------------------------- /profitable.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/profitable/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "profitable" 7 | spec.version = Profitable::VERSION 8 | spec.authors = ["rameerez"] 9 | spec.email = ["rubygems@rameerez.com"] 10 | 11 | spec.summary = "Calculate the MRR, ARR, churn, LTV, ARPU, total revenue & est. valuation of your `pay`-powered Rails SaaS" 12 | spec.description = "Calculate SaaS metrics like the MRR, ARR, churn, LTV, ARPU, total revenue, estimated valuation, and other business metrics of your `pay`-powered Rails app – and display them in a simple dashboard." 13 | spec.homepage = "https://github.com/rameerez/profitable" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.0.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = "https://github.com/rameerez/profitable" 21 | spec.metadata["changelog_uri"] = "https://github.com/rameerez/profitable/blob/main/CHANGELOG.md" 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | gemspec = File.basename(__FILE__) 26 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 27 | ls.readlines("\x0", chomp: true).reject do |f| 28 | (f == gemspec) || 29 | f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) 30 | end 31 | end 32 | spec.bindir = "exe" 33 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 34 | spec.require_paths = ["lib"] 35 | 36 | spec.add_dependency "pay", ">= 7.0.0" 37 | spec.add_dependency "activesupport", ">= 5.2" 38 | 39 | # Uncomment to register a new dependency of your gem 40 | # spec.add_dependency "example-gem", "~> 1.0" 41 | 42 | # For more information and examples about making a new gem, check out our 43 | # guide at: https://bundler.io/guides/creating_gem.html 44 | end 45 | -------------------------------------------------------------------------------- /profitable.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/profitable/47ed9ab91228a6966965a624ef28262378ed1f80/profitable.webp -------------------------------------------------------------------------------- /sig/profitable.rbs: -------------------------------------------------------------------------------- 1 | module Profitable 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | --------------------------------------------------------------------------------