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 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