profitable 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +166 -0
- data/Rakefile +4 -0
- data/app/controllers/profitable/base_controller.rb +5 -0
- data/app/controllers/profitable/dashboard_controller.rb +12 -0
- data/app/views/layouts/profitable/application.html.erb +12 -0
- data/app/views/profitable/dashboard/index.html.erb +106 -0
- data/config/routes.rb +3 -0
- data/lib/profitable/engine.rb +15 -0
- data/lib/profitable/error.rb +3 -0
- data/lib/profitable/mrr_calculator.rb +55 -0
- data/lib/profitable/numeric_result.rb +43 -0
- data/lib/profitable/processors/base.rb +35 -0
- data/lib/profitable/processors/braintree_processor.rb +14 -0
- data/lib/profitable/processors/paddle_billing_processor.rb +17 -0
- data/lib/profitable/processors/paddle_classic_processor.rb +14 -0
- data/lib/profitable/processors/stripe_processor.rb +20 -0
- data/lib/profitable/version.rb +5 -0
- data/lib/profitable.rb +189 -0
- data/profitable.webp +0 -0
- data/sig/profitable.rbs +4 -0
- metadata +100 -0
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
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,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,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,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
|
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
|
data/sig/profitable.rbs
ADDED
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: []
|