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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f82abb3e6ad9cf2adc1c3bc0784458f78151171767d5435844506937432b06f1
4
- data.tar.gz: c688c5cff7678cdbe18fce6d450040b49a4055329953ca2845dce01923ed396a
3
+ metadata.gz: bf77ba92fb941bb21624c1f677bbfff37d874fefa06050e1fe6b395ae55ea3e2
4
+ data.tar.gz: 8d4cf7614e186d7082692faa36794916111205293a45aac055565c2e7923a24b
5
5
  SHA512:
6
- metadata.gz: 209dd44cb7bbeeca27cd6cd738470bc2179e1cb748669e5c740b6e9d583fcb2a22f6e7af3af87c37b2b9c1967b05d30fe0076d3bf44bf2c791aa9299af94c1d4
7
- data.tar.gz: e50c1a56b94f3da49bafa494dfc77a593ef82df75b7d26211e1c5860846a020897d187e006eba1d451a8694e1bd14eb0e951df66eee5c48d83828c9a60fe03f2
6
+ metadata.gz: 73c2251e3d2566b1d22f7319d179f9d8f1077dd70354add0fb8718232f094f613fd4aef048a0accbba8214d5ac4911497d4c6704199cf17470be6ed5ad485e06
7
+ data.tar.gz: 30abc5f18398a6e1553effc66b59407f43610a90a01448dae90dab8eeb74198921593c3e6cb8bea9b63cce14f9435eccb69a53c1d71912b9214392b3e7375e20
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
- ## [Unreleased]
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 subscribers
64
- - `Profitable.new_customers(in_the_last: 30.days)`: Number of new customers (both subscribers and non-subscribers) added in the specified period
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 as a percentage
71
- - `Profitable.estimated_valuation(multiplier = "3x")`: Estimated valuation based on ARR
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.mrr_growth_rate(period: 30.days)`: Calculates the MRR growth rate over the specified period
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 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
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profitable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
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 estimated_valuation(multiplier = "3x")
38
- NumericResult.new(calculate_estimated_valuation(multiplier))
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(Pay::Customer.count, :integer)
55
+ NumericResult.new(calculate_total_customers, :integer)
43
56
  end
44
57
 
45
58
  def total_subscribers
46
- NumericResult.new(Pay::Subscription.active.distinct.count('customer_id'), :integer)
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(Pay::Customer.where(created_at: in_the_last.ago..Time.current).count, :integer)
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
- Pay::Charge.sum(:amount)
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 = "3x")
111
- multiplier = multiplier.to_s.gsub('x', '').to_f
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.0
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-30 00:00:00.000000000 Z
11
+ date: 2024-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pay