profitable 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f82abb3e6ad9cf2adc1c3bc0784458f78151171767d5435844506937432b06f1
4
+ data.tar.gz: c688c5cff7678cdbe18fce6d450040b49a4055329953ca2845dce01923ed396a
5
+ SHA512:
6
+ metadata.gz: 209dd44cb7bbeeca27cd6cd738470bc2179e1cb748669e5c740b6e9d583fcb2a22f6e7af3af87c37b2b9c1967b05d30fe0076d3bf44bf2c791aa9299af94c1d4
7
+ data.tar.gz: e50c1a56b94f3da49bafa494dfc77a593ef82df75b7d26211e1c5860846a020897d187e006eba1d451a8694e1bd14eb0e951df66eee5c48d83828c9a60fe03f2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-08-29
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,166 @@
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.new_mrr(in_the_last: 30.days)`: New MRR added in the specified period
56
+ - `Profitable.churned_mrr(in_the_last: 30.days)`: MRR lost due to churn in the specified period
57
+ - `Profitable.average_revenue_per_customer`: Average revenue per customer (ARPC)
58
+ - `Profitable.lifetime_value`: Estimated customer lifetime value (LTV)
59
+
60
+ ### Customer metrics
61
+
62
+ - `Profitable.total_customers`: Total number of customers
63
+ - `Profitable.total_subscribers`: Total number of active subscribers
64
+ - `Profitable.new_customers(in_the_last: 30.days)`: Number of new customers (both subscribers and non-subscribers) added in the specified period
65
+ - `Profitable.new_subscribers(in_the_last: 30.days)`: Number of new subscribers added in the specified period
66
+ - `Profitable.churned_customers(in_the_last: 30.days)`: Number of customers who churned in the specified period
67
+
68
+ ### Other metrics
69
+
70
+ - `Profitable.churn(in_the_last: 30.days)`: Churn rate as a percentage
71
+ - `Profitable.estimated_valuation(multiplier = "3x")`: Estimated valuation based on ARR
72
+
73
+ ### Growth metrics
74
+
75
+ - `Profitable.mrr_growth_rate(period: 30.days)`: Calculates the MRR growth rate over the specified period
76
+
77
+ ### Milestone metrics
78
+
79
+ - `Profitable.time_to_next_mrr_milestone`: Estimates the time to reach the next MRR milestone
80
+
81
+ ### Usage examples
82
+
83
+ ```ruby
84
+ # Get the current MRR
85
+ Profitable.mrr.to_readable # => "$1,234"
86
+
87
+ # Get the number of new customers in the last 60 days
88
+ Profitable.new_customers(in_the_last: 60.days).to_readable # => "42"
89
+
90
+ # Get the churn rate for the last quarter
91
+ Profitable.churn(in_the_last: 3.months).to_readable # => "12%"
92
+
93
+ # You can specify the precision of the output number (no decimals by default)
94
+ Profitable.new_mrr(in_the_last: 24.hours).to_readable(2) # => "$123.45"
95
+
96
+ # Get the estimated valuation at 5x ARR
97
+ Profitable.estimated_valuation("5x").to_readable # => "$500,000"
98
+
99
+ # Get the time to next MRR milestone
100
+ Profitable.time_to_next_mrr_milestone.to_readable # => "26 days left to $10,000 MRR"
101
+ ```
102
+
103
+ All time-based methods default to a 30-day period if no time range is specified.
104
+
105
+ ### Numeric values and readable format
106
+
107
+ Numeric values are returned in the same currency as your `pay` configuration. The `to_readable` method returns a human-readable format:
108
+
109
+ - Currency values are prefixed with "$" and formatted as currency.
110
+ - Percentage values are suffixed with "%" and formatted as percentages.
111
+ - Integer values are formatted with thousands separators but without currency symbols.
112
+
113
+ For more precise calculations, you can access the raw numeric value:
114
+ ```ruby
115
+ # Returns the raw MRR integer value in cents (123456 equals $1.234,56)
116
+ Profitable.mrr # => 123456
117
+ ```
118
+
119
+ ### Notes on specific metrics
120
+
121
+ - `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.
122
+ - `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.
123
+
124
+ ## Mount the `/profitable` dashboard
125
+
126
+ We also provide a simple dashboard with good defaults to see your main business metrics.
127
+
128
+ In your `config/routes.rb` file, mount the `profitable` engine:
129
+ ```ruby
130
+ mount Profitable::Engine => '/profitable'
131
+ ```
132
+
133
+ It's a good idea to make sure you're adding some sort of authentication to the `/profitable` route to avoid exposing sensitive information:
134
+ ```ruby
135
+ authenticate :user, ->(user) { user.admin? } do
136
+ mount Profitable::Engine => '/profitable'
137
+ end
138
+ ```
139
+
140
+ You can now navigate to `/profitable` to see your app's business metrics like MRR, ARR, churn, etc.
141
+
142
+ ## Development
143
+
144
+ 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.
145
+
146
+ To install this gem onto your local machine, run `bundle exec rake install`.
147
+
148
+ ## TODO
149
+ - [ ] Add active customers (not just total customers)
150
+ - [ ] Add revenue last month to dashboard
151
+ - [ ] Add % of change over last period
152
+ - [ ] Support other currencies other than USD
153
+ - [ ] Support for multiple plans (churn by plan, MRR by plan, etc)
154
+ - [ ] Make sure other payment processors other than Stripe work as intended
155
+ - [ ] Account for subscription upgrades/downgrades within a period
156
+ - [ ] Add a way to input monthly costs (maybe via config file?) so that we can calculate a profit margin %
157
+ - [ ] Allow dashboard configuration via config file
158
+ - [ ] Return a JSON in the dashboard endpoint with main metrics (for monitoring / downstream consumption)
159
+
160
+ ## Contributing
161
+
162
+ 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.
163
+
164
+ ## License
165
+
166
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,5 @@
1
+ module Profitable
2
+ class BaseController < ApplicationController
3
+ layout 'profitable/application'
4
+ end
5
+ end
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>💸 <%= Rails.application.class.module_parent_name %> SaaS Dashboard</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
8
+ </head>
9
+ <body>
10
+ <%= yield %>
11
+ </body>
12
+ </html>
@@ -0,0 +1,106 @@
1
+ <style>
2
+ .card-grid {
3
+ display: flex;
4
+ flex-wrap: wrap;
5
+ gap: 16px; /* Adjusts the space between cards */
6
+ padding: 16px; /* Padding around the grid */
7
+ }
8
+
9
+ .card {
10
+ background-color: var(--bg);
11
+ border: 1px solid var(--border);
12
+ border-radius: var(--standard-border-radius);
13
+ padding: 16px;
14
+ flex: 1 1 calc(33.333% - 32px); /* 3 columns, with gap taken into account */
15
+ box-sizing: border-box;
16
+ text-align: center;
17
+ }
18
+
19
+ /* Make the grid responsive */
20
+ @media (max-width: 900px) {
21
+ .card {
22
+ flex: 1 1 calc(50% - 32px); /* 2 columns for tablets/smaller screens */
23
+ }
24
+ }
25
+
26
+ @media (max-width: 600px) {
27
+ .card {
28
+ flex: 1 1 100%; /* 1 column for mobile devices */
29
+ }
30
+ }
31
+ </style>
32
+
33
+ <header>
34
+ <h1>💸 <%= Rails.application.class.module_parent_name %></h1>
35
+ <% if Profitable.mrr_growth_rate > 0 %>
36
+ <p><%= Profitable.time_to_next_mrr_milestone %></p>
37
+ <% end %>
38
+ </header>
39
+
40
+ <main>
41
+
42
+ <div class="card-grid">
43
+ <div class="card">
44
+ <h2><%= Profitable.total_customers.to_readable %></h2>
45
+ <p>total customers</p>
46
+ </div>
47
+ <div class="card">
48
+ <h2><%= Profitable.mrr.to_readable %></h2>
49
+ <p>MRR</p>
50
+ </div>
51
+ <div class="card">
52
+ <h2><%= Profitable.estimated_valuation.to_readable %></h2>
53
+ <p>Valuation at 3x ARR</p>
54
+ </div>
55
+ <div class="card">
56
+ <h2><%= Profitable.mrr_growth_rate.to_readable %></h2>
57
+ <p>MRR growth rate</p>
58
+ </div>
59
+ <div class="card">
60
+ <h2><%= Profitable.average_revenue_per_customer.to_readable %></h2>
61
+ <p>ARPC</p>
62
+ </div>
63
+ <div class="card">
64
+ <h2><%= Profitable.lifetime_value.to_readable %></h2>
65
+ <p>LTV</p>
66
+ </div>
67
+ <div class="card">
68
+ <h2><%= Profitable.all_time_revenue.to_readable %></h2>
69
+ <p>All-time revenue</p>
70
+ </div>
71
+ </div>
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
+ <h2>Last <%= period.inspect %></h2>
77
+
78
+ <div class="card-grid">
79
+ <div class="card">
80
+ <h2><%= Profitable.new_customers(in_the_last: period).to_readable %></h2>
81
+ <p>new customers (<%= period_short %>)</p>
82
+ </div>
83
+ <div class="card">
84
+ <h2><%= Profitable.churned_customers(in_the_last: period).to_readable %></h2>
85
+ <p>churned customers (<%= period_short %>)</p>
86
+ </div>
87
+ <div class="card">
88
+ <h2><%= Profitable.churn(in_the_last: period).to_readable %></h2>
89
+ <p>churn (<%= period_short %>)</p>
90
+ </div>
91
+ <div class="card">
92
+ <h2><%= Profitable.new_mrr(in_the_last: period).to_readable %></h2>
93
+ <p>new MRR (<%= period_short %>)</p>
94
+ </div>
95
+ <div class="card">
96
+ <h2><%= Profitable.churned_mrr(in_the_last: period).to_readable %></h2>
97
+ <p>churned MRR (<%= period_short %>)</p>
98
+ </div>
99
+ </div>
100
+ <% end %>
101
+
102
+ </main>
103
+
104
+ <footer>
105
+ <p>💸 <code>profitable</code> gem by <a href="https://rameerez.com">@rameerez</a></p>
106
+ </footer>
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ Profitable::Engine.routes.draw do
2
+ root to: "dashboard#index"
3
+ end
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,3 @@
1
+ module Profitable
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,55 @@
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
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
+ processor_class.new(subscription).calculate_mrr
34
+ rescue => e
35
+ Rails.logger.error("Error calculating MRR for subscription #{subscription.id}: #{e.message}")
36
+ 0
37
+ end
38
+
39
+ def self.processor_for(processor_name)
40
+ case processor_name
41
+ when 'stripe'
42
+ Processors::StripeProcessor
43
+ when 'braintree'
44
+ Processors::BraintreeProcessor
45
+ when 'paddle_billing'
46
+ Processors::PaddleBillingProcessor
47
+ when 'paddle_classic'
48
+ Processors::PaddleClassicProcessor
49
+ else
50
+ Rails.logger.warn("Unknown processor: #{processor_name}")
51
+ Processors::Base
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profitable
4
+ VERSION = "0.1.0"
5
+ end
data/lib/profitable.rb ADDED
@@ -0,0 +1,189 @@
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 = [100, 1_000, 10_000, 100_000, 1_000_000, 10_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 estimated_valuation(multiplier = "3x")
38
+ NumericResult.new(calculate_estimated_valuation(multiplier))
39
+ end
40
+
41
+ def total_customers
42
+ NumericResult.new(Pay::Customer.count, :integer)
43
+ end
44
+
45
+ def total_subscribers
46
+ NumericResult.new(Pay::Subscription.active.distinct.count('customer_id'), :integer)
47
+ end
48
+
49
+ def new_customers(in_the_last: DEFAULT_PERIOD)
50
+ NumericResult.new(Pay::Customer.where(created_at: in_the_last.ago..Time.current).count, :integer)
51
+ end
52
+
53
+ def new_subscribers(in_the_last: DEFAULT_PERIOD)
54
+ NumericResult.new(
55
+ Pay::Subscription.active
56
+ .where(pay_subscriptions: { created_at: in_the_last.ago..Time.current })
57
+ .distinct.count('pay_customers.id'),
58
+ :integer
59
+ )
60
+ end
61
+
62
+ def churned_customers(in_the_last: DEFAULT_PERIOD)
63
+ NumericResult.new(calculate_churned_customers(in_the_last), :integer)
64
+ end
65
+
66
+ def new_mrr(in_the_last: DEFAULT_PERIOD)
67
+ NumericResult.new(calculate_new_mrr(in_the_last))
68
+ end
69
+
70
+ def churned_mrr(in_the_last: DEFAULT_PERIOD)
71
+ NumericResult.new(calculate_churned_mrr(in_the_last))
72
+ end
73
+
74
+ def average_revenue_per_customer
75
+ NumericResult.new(calculate_average_revenue_per_customer)
76
+ end
77
+
78
+ def lifetime_value
79
+ NumericResult.new(calculate_lifetime_value)
80
+ end
81
+
82
+ def mrr_growth_rate(in_the_last: DEFAULT_PERIOD)
83
+ NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage)
84
+ end
85
+
86
+ def time_to_next_mrr_milestone
87
+ current_mrr = (mrr.to_i)/100
88
+ next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr }
89
+ return "Congratulations! You've reached the highest milestone." unless next_milestone
90
+
91
+ growth_rate = calculate_mrr_growth_rate
92
+ return "Unable to calculate. Need more data or positive growth." if growth_rate <= 0
93
+
94
+ months_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + growth_rate)).ceil
95
+ days_to_milestone = months_to_milestone * 30
96
+
97
+ return "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{(Time.current + days_to_milestone.days).strftime('%b %d, %Y')})"
98
+ end
99
+
100
+ private
101
+
102
+ def calculate_all_time_revenue
103
+ Pay::Charge.sum(:amount)
104
+ end
105
+
106
+ def calculate_arr
107
+ (mrr.to_f * 12).round
108
+ end
109
+
110
+ def calculate_estimated_valuation(multiplier = "3x")
111
+ multiplier = multiplier.to_s.gsub('x', '').to_f
112
+ (calculate_arr * multiplier).round
113
+ end
114
+
115
+ def calculate_churn(period = DEFAULT_PERIOD)
116
+ start_date = period.ago
117
+ total_subscribers_start = Pay::Subscription.active.where('created_at < ?', start_date).distinct.count('customer_id')
118
+ churned = calculate_churned_customers(period)
119
+ return 0 if total_subscribers_start == 0
120
+ (churned.to_f / total_subscribers_start * 100).round(2)
121
+ end
122
+
123
+ def churned_subscriptions(period = DEFAULT_PERIOD)
124
+ Pay::Subscription
125
+ .where(status: ['canceled', 'ended'])
126
+ .where(ends_at: period.ago..Time.current)
127
+ end
128
+
129
+ def calculate_churned_customers(period = DEFAULT_PERIOD)
130
+ churned_subscriptions(period).distinct.count('customer_id')
131
+ end
132
+
133
+ def calculate_churned_mrr(period = DEFAULT_PERIOD)
134
+ churned_subscriptions(period).sum do |subscription|
135
+ MrrCalculator.process_subscription(subscription)
136
+ end
137
+ end
138
+
139
+ def calculate_new_mrr(period = DEFAULT_PERIOD)
140
+ new_subscriptions = Pay::Subscription
141
+ .active
142
+ .where(pay_subscriptions: { created_at: period.ago..Time.current })
143
+ .where.not(status: ['trialing', 'paused'])
144
+ .includes(:customer)
145
+ .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
146
+ .joins(:customer)
147
+
148
+ new_subscriptions.sum do |subscription|
149
+ MrrCalculator.process_subscription(subscription)
150
+ end
151
+ end
152
+
153
+ def calculate_average_revenue_per_customer
154
+ return 0 if total_customers.zero?
155
+ (all_time_revenue.to_f / total_customers).round
156
+ end
157
+
158
+ def calculate_lifetime_value
159
+ return 0 if total_customers.zero?
160
+ churn_rate = churn.to_f / 100
161
+ return 0 if churn_rate.zero?
162
+ (average_revenue_per_customer.to_f / churn_rate).round
163
+ end
164
+
165
+ def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
166
+ end_date = Time.current
167
+ start_date = end_date - period
168
+
169
+ start_mrr = calculate_mrr_at(start_date)
170
+ end_mrr = calculate_mrr_at(end_date)
171
+
172
+ return 0 if start_mrr == 0
173
+ ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2)
174
+ end
175
+
176
+ def calculate_mrr_at(date)
177
+ Pay::Subscription
178
+ .active
179
+ .where('pay_subscriptions.created_at <= ?', date)
180
+ .where.not(status: ['trialing', 'paused'])
181
+ .includes(:customer)
182
+ .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
183
+ .joins(:customer)
184
+ .sum do |subscription|
185
+ MrrCalculator.process_subscription(subscription)
186
+ end
187
+ end
188
+ end
189
+ end
data/profitable.webp ADDED
Binary file
@@ -0,0 +1,4 @@
1
+ module Profitable
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: profitable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rameerez
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-08-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pay
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.2'
41
+ description: Calculate SaaS metrics like the MRR, ARR, churn, LTV, ARPU, total revenue,
42
+ estimated valuation, and other business metrics of your `pay`-powered Rails app
43
+ – and display them in a simple dashboard.
44
+ email:
45
+ - rubygems@rameerez.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - app/controllers/profitable/base_controller.rb
55
+ - app/controllers/profitable/dashboard_controller.rb
56
+ - app/views/layouts/profitable/application.html.erb
57
+ - app/views/profitable/dashboard/index.html.erb
58
+ - config/routes.rb
59
+ - lib/profitable.rb
60
+ - lib/profitable/engine.rb
61
+ - lib/profitable/error.rb
62
+ - lib/profitable/mrr_calculator.rb
63
+ - lib/profitable/numeric_result.rb
64
+ - lib/profitable/processors/base.rb
65
+ - lib/profitable/processors/braintree_processor.rb
66
+ - lib/profitable/processors/paddle_billing_processor.rb
67
+ - lib/profitable/processors/paddle_classic_processor.rb
68
+ - lib/profitable/processors/stripe_processor.rb
69
+ - lib/profitable/version.rb
70
+ - profitable.webp
71
+ - sig/profitable.rbs
72
+ homepage: https://github.com/rameerez/profitable
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ allowed_push_host: https://rubygems.org
77
+ homepage_uri: https://github.com/rameerez/profitable
78
+ source_code_uri: https://github.com/rameerez/profitable
79
+ changelog_uri: https://github.com/rameerez/profitable
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 3.0.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.5.17
96
+ signing_key:
97
+ specification_version: 4
98
+ summary: Calculate the MRR, ARR, churn, LTV, ARPU, total revenue & est. valuation
99
+ of your `pay`-powered Rails SaaS
100
+ test_files: []