profitable 0.1.0 → 0.2.0
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 +6 -2
- data/README.md +21 -13
- 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 +93 -10
- 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: cd833a8b05f27ea248099d3965e9d491a13f90c51c0ad29aa524384d6b92ddce
|
4
|
+
data.tar.gz: 3b57ba527cc3ac287688222be53415d16f9bdf2635b07496883e50da3671e77f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: daf28e6f4a6da261bb56fce23abb8a0a4f417d2c637eff0c1d696abcd19ebd7f123a283eddffbf66509ef9edf08f4e2ca4e0d9818c64ee9b2f7a188c7dafd2ca
|
7
|
+
data.tar.gz: 982180e6b08385d430a3a7576d9e474ff96d108d086aec428272ca2d2399489eb663837efe85f0b03fe830350cab72ab25e264d498951fed1f4cef72c6231ccf
|
data/CHANGELOG.md
CHANGED
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(multiplier = "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
|
|
@@ -146,15 +153,16 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
146
153
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
147
154
|
|
148
155
|
## TODO
|
156
|
+
- [ ] Calculate split by plan / add support for multiple plans (churn by plan, MRR by plan, etc) – not just aggregated
|
157
|
+
- [ ] Calculate MRR expansion (plan upgrades), contraction (plan downgrades), etc. like Stripe does
|
149
158
|
- [ ] 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
|
159
|
+
- [ ] Add % of change over last period (this period vs last period)
|
160
|
+
- [ ] Calculate total period revenue vs period recurring revenue (started, but not sure if accurate)
|
161
|
+
- [ ] Add revenue last month to dashboard (not just past 30d, like previous month)
|
162
|
+
- [ ] Support other currencies other than USD (convert currencies)
|
163
|
+
- [ ] Make sure other payment processors other than Stripe work as intended (Paddle, Braintree, etc. – I've never used them)
|
156
164
|
- [ ] 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
|
165
|
+
- [ ] Allow dashboard configuration via config file (which metrics to show, etc.)
|
158
166
|
- [ ] Return a JSON in the dashboard endpoint with main metrics (for monitoring / downstream consumption)
|
159
167
|
|
160
168
|
## 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,40 @@ module Profitable
|
|
34
34
|
NumericResult.new(calculate_all_time_revenue)
|
35
35
|
end
|
36
36
|
|
37
|
+
def revenue_in_period(in_the_last: DEFAULT_PERIOD)
|
38
|
+
NumericResult.new(calculate_revenue_in_period(in_the_last))
|
39
|
+
end
|
40
|
+
|
41
|
+
def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD)
|
42
|
+
NumericResult.new(calculate_recurring_revenue_in_period(in_the_last))
|
43
|
+
end
|
44
|
+
|
45
|
+
def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD)
|
46
|
+
NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage)
|
47
|
+
end
|
48
|
+
|
37
49
|
def estimated_valuation(multiplier = "3x")
|
38
50
|
NumericResult.new(calculate_estimated_valuation(multiplier))
|
39
51
|
end
|
40
52
|
|
41
53
|
def total_customers
|
42
|
-
NumericResult.new(
|
54
|
+
NumericResult.new(calculate_total_customers, :integer)
|
43
55
|
end
|
44
56
|
|
45
57
|
def total_subscribers
|
46
|
-
NumericResult.new(
|
58
|
+
NumericResult.new(calculate_total_subscribers, :integer)
|
59
|
+
end
|
60
|
+
|
61
|
+
def active_subscribers
|
62
|
+
NumericResult.new(calculate_active_subscribers, :integer)
|
47
63
|
end
|
48
64
|
|
49
65
|
def new_customers(in_the_last: DEFAULT_PERIOD)
|
50
|
-
NumericResult.new(
|
66
|
+
NumericResult.new(calculate_new_customers(in_the_last), :integer)
|
51
67
|
end
|
52
68
|
|
53
69
|
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
|
-
)
|
70
|
+
NumericResult.new(calculate_new_subscribers(in_the_last), :integer)
|
60
71
|
end
|
61
72
|
|
62
73
|
def churned_customers(in_the_last: DEFAULT_PERIOD)
|
@@ -79,6 +90,10 @@ module Profitable
|
|
79
90
|
NumericResult.new(calculate_lifetime_value)
|
80
91
|
end
|
81
92
|
|
93
|
+
def mrr_growth(in_the_last: DEFAULT_PERIOD)
|
94
|
+
NumericResult.new(calculate_mrr_growth(in_the_last))
|
95
|
+
end
|
96
|
+
|
82
97
|
def mrr_growth_rate(in_the_last: DEFAULT_PERIOD)
|
83
98
|
NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage)
|
84
99
|
end
|
@@ -99,8 +114,13 @@ module Profitable
|
|
99
114
|
|
100
115
|
private
|
101
116
|
|
117
|
+
def paid_charges
|
118
|
+
Pay::Charge.where("(pay_charges.data ->> 'paid' IS NULL OR pay_charges.data ->> 'paid' != ?) AND pay_charges.amount > 0", 'false')
|
119
|
+
.where("pay_charges.data ->> 'status' = ? OR pay_charges.data ->> 'status' IS NULL", 'succeeded')
|
120
|
+
end
|
121
|
+
|
102
122
|
def calculate_all_time_revenue
|
103
|
-
|
123
|
+
paid_charges.sum(:amount)
|
104
124
|
end
|
105
125
|
|
106
126
|
def calculate_arr
|
@@ -122,6 +142,9 @@ module Profitable
|
|
122
142
|
|
123
143
|
def churned_subscriptions(period = DEFAULT_PERIOD)
|
124
144
|
Pay::Subscription
|
145
|
+
.includes(:customer)
|
146
|
+
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
147
|
+
.joins(:customer)
|
125
148
|
.where(status: ['canceled', 'ended'])
|
126
149
|
.where(ends_at: period.ago..Time.current)
|
127
150
|
end
|
@@ -150,6 +173,59 @@ module Profitable
|
|
150
173
|
end
|
151
174
|
end
|
152
175
|
|
176
|
+
def calculate_revenue_in_period(period)
|
177
|
+
paid_charges.where(created_at: period.ago..Time.current).sum(:amount)
|
178
|
+
end
|
179
|
+
|
180
|
+
def calculate_recurring_revenue_in_period(period)
|
181
|
+
paid_charges
|
182
|
+
.joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id')
|
183
|
+
.where(created_at: period.ago..Time.current)
|
184
|
+
.sum(:amount)
|
185
|
+
end
|
186
|
+
|
187
|
+
def calculate_recurring_revenue_percentage(period)
|
188
|
+
total_revenue = calculate_revenue_in_period(period)
|
189
|
+
recurring_revenue = calculate_recurring_revenue_in_period(period)
|
190
|
+
|
191
|
+
return 0 if total_revenue.zero?
|
192
|
+
|
193
|
+
((recurring_revenue.to_f / total_revenue) * 100).round(2)
|
194
|
+
end
|
195
|
+
|
196
|
+
def calculate_total_customers
|
197
|
+
actual_customers.count
|
198
|
+
end
|
199
|
+
|
200
|
+
def calculate_total_subscribers
|
201
|
+
Pay::Customer.joins(:subscriptions).distinct.count
|
202
|
+
end
|
203
|
+
|
204
|
+
def calculate_active_subscribers
|
205
|
+
Pay::Customer.joins(:subscriptions)
|
206
|
+
.where(pay_subscriptions: { status: 'active' })
|
207
|
+
.distinct
|
208
|
+
.count
|
209
|
+
end
|
210
|
+
|
211
|
+
def actual_customers
|
212
|
+
Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id")
|
213
|
+
.joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id")
|
214
|
+
.where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0")
|
215
|
+
.distinct
|
216
|
+
end
|
217
|
+
|
218
|
+
def calculate_new_customers(period)
|
219
|
+
actual_customers.where(created_at: period.ago..Time.current).count
|
220
|
+
end
|
221
|
+
|
222
|
+
def calculate_new_subscribers(period)
|
223
|
+
Pay::Customer.joins(:subscriptions)
|
224
|
+
.where(created_at: period.ago..Time.current)
|
225
|
+
.distinct
|
226
|
+
.count
|
227
|
+
end
|
228
|
+
|
153
229
|
def calculate_average_revenue_per_customer
|
154
230
|
return 0 if total_customers.zero?
|
155
231
|
(all_time_revenue.to_f / total_customers).round
|
@@ -162,6 +238,12 @@ module Profitable
|
|
162
238
|
(average_revenue_per_customer.to_f / churn_rate).round
|
163
239
|
end
|
164
240
|
|
241
|
+
def calculate_mrr_growth(period = DEFAULT_PERIOD)
|
242
|
+
new_mrr = calculate_new_mrr(period)
|
243
|
+
churned_mrr = calculate_churned_mrr(period)
|
244
|
+
new_mrr - churned_mrr
|
245
|
+
end
|
246
|
+
|
165
247
|
def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
|
166
248
|
end_date = Time.current
|
167
249
|
start_date = end_date - period
|
@@ -185,5 +267,6 @@ module Profitable
|
|
185
267
|
MrrCalculator.process_subscription(subscription)
|
186
268
|
end
|
187
269
|
end
|
270
|
+
|
188
271
|
end
|
189
272
|
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.
|
4
|
+
version: 0.2.0
|
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
|