profitable 0.1.0 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -2
- data/README.md +25 -32
- data/app/views/profitable/dashboard/index.html.erb +11 -0
- data/lib/profitable/mrr_calculator.rb +5 -2
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +113 -14
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bf77ba92fb941bb21624c1f677bbfff37d874fefa06050e1fe6b395ae55ea3e2
|
4
|
+
data.tar.gz: 8d4cf7614e186d7082692faa36794916111205293a45aac055565c2e7923a24b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 73c2251e3d2566b1d22f7319d179f9d8f1077dd70354add0fb8718232f094f613fd4aef048a0accbba8214d5ac4911497d4c6704199cf17470be6ed5ad485e06
|
7
|
+
data.tar.gz: 30abc5f18398a6e1553effc66b59407f43610a90a01448dae90dab8eeb74198921593c3e6cb8bea9b63cce14f9435eccb69a53c1d71912b9214392b3e7375e20
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,14 @@
|
|
1
|
-
|
1
|
+
# `profitable`
|
2
|
+
|
3
|
+
## [0.2.1] - 2024-08-31
|
4
|
+
|
5
|
+
- Add syntactic sugar for `estimated_valuation(at: "3x")`
|
6
|
+
- 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.)
|
7
|
+
|
8
|
+
## [0.2.0] - 2024-08-31
|
9
|
+
|
10
|
+
- Initial production ready release
|
2
11
|
|
3
12
|
## [0.1.0] - 2024-08-29
|
4
13
|
|
5
|
-
- Initial release
|
14
|
+
- Initial test release (not production ready)
|
data/README.md
CHANGED
@@ -52,27 +52,34 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
|
|
52
52
|
- `Profitable.mrr`: Monthly Recurring Revenue (MRR)
|
53
53
|
- `Profitable.arr`: Annual Recurring Revenue (ARR)
|
54
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
|
55
58
|
- `Profitable.new_mrr(in_the_last: 30.days)`: New MRR added in the specified period
|
56
59
|
- `Profitable.churned_mrr(in_the_last: 30.days)`: MRR lost due to churn in the specified period
|
57
60
|
- `Profitable.average_revenue_per_customer`: Average revenue per customer (ARPC)
|
58
61
|
- `Profitable.lifetime_value`: Estimated customer lifetime value (LTV)
|
62
|
+
- `Profitable.estimated_valuation(at: "3x")`: Estimated company valuation based on ARR
|
59
63
|
|
60
64
|
### Customer metrics
|
61
65
|
|
62
|
-
- `Profitable.total_customers`: Total number of customers
|
63
|
-
- `Profitable.total_subscribers`: Total number of active
|
64
|
-
- `Profitable.
|
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
|
65
70
|
- `Profitable.new_subscribers(in_the_last: 30.days)`: Number of new subscribers added in the specified period
|
66
71
|
- `Profitable.churned_customers(in_the_last: 30.days)`: Number of customers who churned in the specified period
|
67
72
|
|
68
73
|
### Other metrics
|
69
74
|
|
70
|
-
- `Profitable.churn(in_the_last: 30.days)`: Churn rate
|
71
|
-
- `Profitable.
|
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
|
72
78
|
|
73
79
|
### Growth metrics
|
74
80
|
|
75
|
-
- `Profitable.
|
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
|
76
83
|
|
77
84
|
### Milestone metrics
|
78
85
|
|
@@ -94,7 +101,10 @@ Profitable.churn(in_the_last: 3.months).to_readable # => "12%"
|
|
94
101
|
Profitable.new_mrr(in_the_last: 24.hours).to_readable(2) # => "$123.45"
|
95
102
|
|
96
103
|
# Get the estimated valuation at 5x ARR
|
97
|
-
Profitable.estimated_valuation("5x").to_readable # => "$500,000"
|
104
|
+
Profitable.estimated_valuation(at: "5x").to_readable # => "$500,000"
|
105
|
+
|
106
|
+
# You can also pass the multiplier as a number, and/or ignore the "at:" keyword altogether
|
107
|
+
Profitable.estimated_valuation(4.5).to_readable # => "$450,000"
|
98
108
|
|
99
109
|
# Get the time to next MRR milestone
|
100
110
|
Profitable.time_to_next_mrr_milestone.to_readable # => "26 days left to $10,000 MRR"
|
@@ -121,24 +131,6 @@ Profitable.mrr # => 123456
|
|
121
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.
|
122
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.
|
123
133
|
|
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
134
|
## Development
|
143
135
|
|
144
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.
|
@@ -146,15 +138,16 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
146
138
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
147
139
|
|
148
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
|
149
143
|
- [ ] Add active customers (not just total customers)
|
150
|
-
- [ ] Add
|
151
|
-
- [ ]
|
152
|
-
- [ ]
|
153
|
-
- [ ] Support
|
154
|
-
- [ ] Make sure other payment processors other than Stripe work as intended
|
155
|
-
- [ ] Account for subscription upgrades/downgrades within a period
|
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)
|
156
149
|
- [ ] 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
|
150
|
+
- [ ] Allow dashboard configuration via config file (which metrics to show, etc.)
|
158
151
|
- [ ] Return a JSON in the dashboard endpoint with main metrics (for monitoring / downstream consumption)
|
159
152
|
|
160
153
|
## Contributing
|
@@ -88,6 +88,7 @@
|
|
88
88
|
<h2><%= Profitable.churn(in_the_last: period).to_readable %></h2>
|
89
89
|
<p>churn (<%= period_short %>)</p>
|
90
90
|
</div>
|
91
|
+
|
91
92
|
<div class="card">
|
92
93
|
<h2><%= Profitable.new_mrr(in_the_last: period).to_readable %></h2>
|
93
94
|
<p>new MRR (<%= period_short %>)</p>
|
@@ -96,6 +97,16 @@
|
|
96
97
|
<h2><%= Profitable.churned_mrr(in_the_last: period).to_readable %></h2>
|
97
98
|
<p>churned MRR (<%= period_short %>)</p>
|
98
99
|
</div>
|
100
|
+
<div class="card">
|
101
|
+
<h2><%= Profitable.mrr_growth(in_the_last: period).to_readable %></h2>
|
102
|
+
<p>MRR growth (<%= period_short %>)</p>
|
103
|
+
</div>
|
104
|
+
|
105
|
+
<div class="card">
|
106
|
+
<h2><%= Profitable.revenue_in_period(in_the_last: period).to_readable %></h2>
|
107
|
+
<p>total revenue (<%= period_short %>)</p>
|
108
|
+
</div>
|
109
|
+
|
99
110
|
</div>
|
100
111
|
<% end %>
|
101
112
|
|
@@ -17,7 +17,7 @@ module Profitable
|
|
17
17
|
|
18
18
|
subscriptions.find_each do |subscription|
|
19
19
|
mrr = process_subscription(subscription)
|
20
|
-
total_mrr += mrr
|
20
|
+
total_mrr += mrr if mrr.is_a?(Numeric) && mrr > 0
|
21
21
|
end
|
22
22
|
|
23
23
|
total_mrr
|
@@ -30,7 +30,10 @@ module Profitable
|
|
30
30
|
return 0 if subscription.nil? || subscription.data.nil?
|
31
31
|
|
32
32
|
processor_class = processor_for(subscription.customer_processor)
|
33
|
-
processor_class.new(subscription).calculate_mrr
|
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
|
34
37
|
rescue => e
|
35
38
|
Rails.logger.error("Error calculating MRR for subscription #{subscription.id}: #{e.message}")
|
36
39
|
0
|
data/lib/profitable/version.rb
CHANGED
data/lib/profitable.rb
CHANGED
@@ -34,29 +34,41 @@ module Profitable
|
|
34
34
|
NumericResult.new(calculate_all_time_revenue)
|
35
35
|
end
|
36
36
|
|
37
|
-
def
|
38
|
-
NumericResult.new(
|
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 = 3, at: nil)
|
50
|
+
actual_multiplier = at || multiplier
|
51
|
+
NumericResult.new(calculate_estimated_valuation(actual_multiplier))
|
39
52
|
end
|
40
53
|
|
41
54
|
def total_customers
|
42
|
-
NumericResult.new(
|
55
|
+
NumericResult.new(calculate_total_customers, :integer)
|
43
56
|
end
|
44
57
|
|
45
58
|
def total_subscribers
|
46
|
-
NumericResult.new(
|
59
|
+
NumericResult.new(calculate_total_subscribers, :integer)
|
60
|
+
end
|
61
|
+
|
62
|
+
def active_subscribers
|
63
|
+
NumericResult.new(calculate_active_subscribers, :integer)
|
47
64
|
end
|
48
65
|
|
49
66
|
def new_customers(in_the_last: DEFAULT_PERIOD)
|
50
|
-
NumericResult.new(
|
67
|
+
NumericResult.new(calculate_new_customers(in_the_last), :integer)
|
51
68
|
end
|
52
69
|
|
53
70
|
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
|
-
)
|
71
|
+
NumericResult.new(calculate_new_subscribers(in_the_last), :integer)
|
60
72
|
end
|
61
73
|
|
62
74
|
def churned_customers(in_the_last: DEFAULT_PERIOD)
|
@@ -79,6 +91,10 @@ module Profitable
|
|
79
91
|
NumericResult.new(calculate_lifetime_value)
|
80
92
|
end
|
81
93
|
|
94
|
+
def mrr_growth(in_the_last: DEFAULT_PERIOD)
|
95
|
+
NumericResult.new(calculate_mrr_growth(in_the_last))
|
96
|
+
end
|
97
|
+
|
82
98
|
def mrr_growth_rate(in_the_last: DEFAULT_PERIOD)
|
83
99
|
NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage)
|
84
100
|
end
|
@@ -99,19 +115,39 @@ module Profitable
|
|
99
115
|
|
100
116
|
private
|
101
117
|
|
118
|
+
def paid_charges
|
119
|
+
Pay::Charge.where("(pay_charges.data ->> 'paid' IS NULL OR pay_charges.data ->> 'paid' != ?) AND pay_charges.amount > 0", 'false')
|
120
|
+
.where("pay_charges.data ->> 'status' = ? OR pay_charges.data ->> 'status' IS NULL", 'succeeded')
|
121
|
+
end
|
122
|
+
|
102
123
|
def calculate_all_time_revenue
|
103
|
-
|
124
|
+
paid_charges.sum(:amount)
|
104
125
|
end
|
105
126
|
|
106
127
|
def calculate_arr
|
107
128
|
(mrr.to_f * 12).round
|
108
129
|
end
|
109
130
|
|
110
|
-
def calculate_estimated_valuation(multiplier =
|
111
|
-
multiplier = multiplier
|
131
|
+
def calculate_estimated_valuation(multiplier = 3)
|
132
|
+
multiplier = parse_multiplier(multiplier)
|
112
133
|
(calculate_arr * multiplier).round
|
113
134
|
end
|
114
135
|
|
136
|
+
def parse_multiplier(input)
|
137
|
+
case input
|
138
|
+
when Numeric
|
139
|
+
input.to_f
|
140
|
+
when String
|
141
|
+
if input.end_with?('x')
|
142
|
+
input.chomp('x').to_f
|
143
|
+
else
|
144
|
+
input.to_f
|
145
|
+
end
|
146
|
+
else
|
147
|
+
3.0 # Default multiplier if input is invalid
|
148
|
+
end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range
|
149
|
+
end
|
150
|
+
|
115
151
|
def calculate_churn(period = DEFAULT_PERIOD)
|
116
152
|
start_date = period.ago
|
117
153
|
total_subscribers_start = Pay::Subscription.active.where('created_at < ?', start_date).distinct.count('customer_id')
|
@@ -122,6 +158,9 @@ module Profitable
|
|
122
158
|
|
123
159
|
def churned_subscriptions(period = DEFAULT_PERIOD)
|
124
160
|
Pay::Subscription
|
161
|
+
.includes(:customer)
|
162
|
+
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
163
|
+
.joins(:customer)
|
125
164
|
.where(status: ['canceled', 'ended'])
|
126
165
|
.where(ends_at: period.ago..Time.current)
|
127
166
|
end
|
@@ -150,6 +189,59 @@ module Profitable
|
|
150
189
|
end
|
151
190
|
end
|
152
191
|
|
192
|
+
def calculate_revenue_in_period(period)
|
193
|
+
paid_charges.where(created_at: period.ago..Time.current).sum(:amount)
|
194
|
+
end
|
195
|
+
|
196
|
+
def calculate_recurring_revenue_in_period(period)
|
197
|
+
paid_charges
|
198
|
+
.joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id')
|
199
|
+
.where(created_at: period.ago..Time.current)
|
200
|
+
.sum(:amount)
|
201
|
+
end
|
202
|
+
|
203
|
+
def calculate_recurring_revenue_percentage(period)
|
204
|
+
total_revenue = calculate_revenue_in_period(period)
|
205
|
+
recurring_revenue = calculate_recurring_revenue_in_period(period)
|
206
|
+
|
207
|
+
return 0 if total_revenue.zero?
|
208
|
+
|
209
|
+
((recurring_revenue.to_f / total_revenue) * 100).round(2)
|
210
|
+
end
|
211
|
+
|
212
|
+
def calculate_total_customers
|
213
|
+
actual_customers.count
|
214
|
+
end
|
215
|
+
|
216
|
+
def calculate_total_subscribers
|
217
|
+
Pay::Customer.joins(:subscriptions).distinct.count
|
218
|
+
end
|
219
|
+
|
220
|
+
def calculate_active_subscribers
|
221
|
+
Pay::Customer.joins(:subscriptions)
|
222
|
+
.where(pay_subscriptions: { status: 'active' })
|
223
|
+
.distinct
|
224
|
+
.count
|
225
|
+
end
|
226
|
+
|
227
|
+
def actual_customers
|
228
|
+
Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id")
|
229
|
+
.joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id")
|
230
|
+
.where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0")
|
231
|
+
.distinct
|
232
|
+
end
|
233
|
+
|
234
|
+
def calculate_new_customers(period)
|
235
|
+
actual_customers.where(created_at: period.ago..Time.current).count
|
236
|
+
end
|
237
|
+
|
238
|
+
def calculate_new_subscribers(period)
|
239
|
+
Pay::Customer.joins(:subscriptions)
|
240
|
+
.where(created_at: period.ago..Time.current)
|
241
|
+
.distinct
|
242
|
+
.count
|
243
|
+
end
|
244
|
+
|
153
245
|
def calculate_average_revenue_per_customer
|
154
246
|
return 0 if total_customers.zero?
|
155
247
|
(all_time_revenue.to_f / total_customers).round
|
@@ -162,6 +254,12 @@ module Profitable
|
|
162
254
|
(average_revenue_per_customer.to_f / churn_rate).round
|
163
255
|
end
|
164
256
|
|
257
|
+
def calculate_mrr_growth(period = DEFAULT_PERIOD)
|
258
|
+
new_mrr = calculate_new_mrr(period)
|
259
|
+
churned_mrr = calculate_churned_mrr(period)
|
260
|
+
new_mrr - churned_mrr
|
261
|
+
end
|
262
|
+
|
165
263
|
def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
|
166
264
|
end_date = Time.current
|
167
265
|
start_date = end_date - period
|
@@ -185,5 +283,6 @@ module Profitable
|
|
185
283
|
MrrCalculator.process_subscription(subscription)
|
186
284
|
end
|
187
285
|
end
|
286
|
+
|
188
287
|
end
|
189
288
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: profitable
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- rameerez
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-08-
|
11
|
+
date: 2024-08-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: pay
|