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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f82abb3e6ad9cf2adc1c3bc0784458f78151171767d5435844506937432b06f1
4
- data.tar.gz: c688c5cff7678cdbe18fce6d450040b49a4055329953ca2845dce01923ed396a
3
+ metadata.gz: cd833a8b05f27ea248099d3965e9d491a13f90c51c0ad29aa524384d6b92ddce
4
+ data.tar.gz: 3b57ba527cc3ac287688222be53415d16f9bdf2635b07496883e50da3671e77f
5
5
  SHA512:
6
- metadata.gz: 209dd44cb7bbeeca27cd6cd738470bc2179e1cb748669e5c740b6e9d583fcb2a22f6e7af3af87c37b2b9c1967b05d30fe0076d3bf44bf2c791aa9299af94c1d4
7
- data.tar.gz: e50c1a56b94f3da49bafa494dfc77a593ef82df75b7d26211e1c5860846a020897d187e006eba1d451a8694e1bd14eb0e951df66eee5c48d83828c9a60fe03f2
6
+ metadata.gz: daf28e6f4a6da261bb56fce23abb8a0a4f417d2c637eff0c1d696abcd19ebd7f123a283eddffbf66509ef9edf08f4e2ca4e0d9818c64ee9b2f7a188c7dafd2ca
7
+ data.tar.gz: 982180e6b08385d430a3a7576d9e474ff96d108d086aec428272ca2d2399489eb663837efe85f0b03fe830350cab72ab25e264d498951fed1f4cef72c6231ccf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
- ## [Unreleased]
1
+ # `profitable`
2
+
3
+ ## [0.2.0] - 2024-08-31
4
+
5
+ - Initial production ready release
2
6
 
3
7
  ## [0.1.0] - 2024-08-29
4
8
 
5
- - Initial release
9
+ - 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(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 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
 
@@ -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 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
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
@@ -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.0"
5
5
  end
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(Pay::Customer.count, :integer)
54
+ NumericResult.new(calculate_total_customers, :integer)
43
55
  end
44
56
 
45
57
  def total_subscribers
46
- NumericResult.new(Pay::Subscription.active.distinct.count('customer_id'), :integer)
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(Pay::Customer.where(created_at: in_the_last.ago..Time.current).count, :integer)
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
- Pay::Charge.sum(:amount)
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.1.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-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