profitable 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|