profitable 0.2.3 → 0.4.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/.simplecov +47 -0
- data/AGENTS.md +5 -0
- data/Appraisals +50 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +5 -0
- data/README.md +57 -5
- data/Rakefile +10 -1
- data/app/controllers/profitable/dashboard_controller.rb +16 -4
- data/app/views/profitable/dashboard/index.html.erb +151 -18
- data/gemfiles/pay_10.0.gemfile +23 -0
- data/gemfiles/pay_11.0.gemfile +23 -0
- data/gemfiles/pay_7.3.gemfile +23 -0
- data/gemfiles/pay_8.3.gemfile +23 -0
- data/gemfiles/pay_9.0.gemfile +23 -0
- data/gemfiles/rails_7.2.gemfile +23 -0
- data/gemfiles/rails_8.1.gemfile +23 -0
- data/lib/profitable/json_helpers.rb +68 -0
- data/lib/profitable/mrr_calculator.rb +13 -3
- data/lib/profitable/processors/base.rb +28 -5
- data/lib/profitable/processors/braintree_processor.rb +8 -3
- data/lib/profitable/processors/paddle_billing_processor.rb +22 -7
- data/lib/profitable/processors/paddle_classic_processor.rb +7 -2
- data/lib/profitable/processors/stripe_processor.rb +24 -8
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +282 -75
- metadata +16 -7
data/lib/profitable.rb
CHANGED
|
@@ -6,14 +6,20 @@ require_relative "profitable/engine"
|
|
|
6
6
|
|
|
7
7
|
require_relative "profitable/mrr_calculator"
|
|
8
8
|
require_relative "profitable/numeric_result"
|
|
9
|
+
require_relative "profitable/json_helpers"
|
|
9
10
|
|
|
10
11
|
require "pay"
|
|
11
12
|
require "active_support/core_ext/numeric/conversions"
|
|
12
13
|
require "action_view"
|
|
13
14
|
|
|
14
15
|
module Profitable
|
|
16
|
+
# Subscription status constants (at module level so MrrCalculator can reference them)
|
|
17
|
+
EXCLUDED_STATUSES = ['trialing', 'paused'].freeze
|
|
18
|
+
CHURNED_STATUSES = ['canceled', 'ended'].freeze
|
|
19
|
+
|
|
15
20
|
class << self
|
|
16
21
|
include ActionView::Helpers::NumberHelper
|
|
22
|
+
include Profitable::JsonHelpers
|
|
17
23
|
|
|
18
24
|
DEFAULT_PERIOD = 30.days
|
|
19
25
|
MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000]
|
|
@@ -100,7 +106,9 @@ module Profitable
|
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
def time_to_next_mrr_milestone
|
|
103
|
-
current_mrr = (mrr.to_i)/100
|
|
109
|
+
current_mrr = (mrr.to_i) / 100 # Convert cents to dollars
|
|
110
|
+
return "Unable to calculate. No MRR yet." if current_mrr <= 0
|
|
111
|
+
|
|
104
112
|
next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr }
|
|
105
113
|
return "Congratulations! You've reached the highest milestone." unless next_milestone
|
|
106
114
|
|
|
@@ -109,6 +117,7 @@ module Profitable
|
|
|
109
117
|
|
|
110
118
|
# Convert monthly growth rate to daily growth rate
|
|
111
119
|
daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1
|
|
120
|
+
return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0
|
|
112
121
|
|
|
113
122
|
# Calculate the number of days to reach the next milestone
|
|
114
123
|
days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil
|
|
@@ -118,11 +127,56 @@ module Profitable
|
|
|
118
127
|
"#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
|
|
119
128
|
end
|
|
120
129
|
|
|
130
|
+
def monthly_summary(months: 12)
|
|
131
|
+
calculate_monthly_summary(months)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def daily_summary(days: 30)
|
|
135
|
+
calculate_daily_summary(days)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def period_data(in_the_last: DEFAULT_PERIOD)
|
|
139
|
+
calculate_period_data(in_the_last)
|
|
140
|
+
end
|
|
141
|
+
|
|
121
142
|
private
|
|
122
143
|
|
|
144
|
+
# Helper to load subscriptions with processor info from customer
|
|
145
|
+
def subscriptions_with_processor(scope = Pay::Subscription.all)
|
|
146
|
+
scope
|
|
147
|
+
.includes(:customer)
|
|
148
|
+
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
149
|
+
.joins(:customer)
|
|
150
|
+
end
|
|
151
|
+
|
|
123
152
|
def paid_charges
|
|
124
|
-
Pay
|
|
125
|
-
|
|
153
|
+
# Pay gem v10+ stores charge data in `object` column, older versions used `data`
|
|
154
|
+
# We check both columns for backwards compatibility using database-agnostic JSON extraction
|
|
155
|
+
#
|
|
156
|
+
# Performance note: The COALESCE pattern may prevent index usage on some databases.
|
|
157
|
+
# This is an acceptable tradeoff for backwards compatibility with Pay < 10.
|
|
158
|
+
# For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+
|
|
159
|
+
# where only the `object` column is used.
|
|
160
|
+
|
|
161
|
+
# Build JSON extraction SQL for both object and data columns
|
|
162
|
+
paid_object = json_extract('pay_charges.object', 'paid')
|
|
163
|
+
paid_data = json_extract('pay_charges.data', 'paid')
|
|
164
|
+
status_object = json_extract('pay_charges.object', 'status')
|
|
165
|
+
status_data = json_extract('pay_charges.data', 'status')
|
|
166
|
+
|
|
167
|
+
Pay::Charge
|
|
168
|
+
.where("pay_charges.amount > 0")
|
|
169
|
+
.where(<<~SQL.squish, 'false', 'succeeded')
|
|
170
|
+
(
|
|
171
|
+
(COALESCE(#{paid_object}, #{paid_data}) IS NULL
|
|
172
|
+
OR COALESCE(#{paid_object}, #{paid_data}) != ?)
|
|
173
|
+
)
|
|
174
|
+
AND
|
|
175
|
+
(
|
|
176
|
+
COALESCE(#{status_object}, #{status_data}) = ?
|
|
177
|
+
OR COALESCE(#{status_object}, #{status_data}) IS NULL
|
|
178
|
+
)
|
|
179
|
+
SQL
|
|
126
180
|
end
|
|
127
181
|
|
|
128
182
|
def calculate_all_time_revenue
|
|
@@ -154,71 +208,19 @@ module Profitable
|
|
|
154
208
|
end
|
|
155
209
|
|
|
156
210
|
def calculate_churn(period = DEFAULT_PERIOD)
|
|
157
|
-
|
|
158
|
-
total_subscribers_start = Pay::Subscription.active.where('created_at < ?', start_date).distinct.count('customer_id')
|
|
159
|
-
churned = calculate_churned_customers(period)
|
|
160
|
-
return 0 if total_subscribers_start == 0
|
|
161
|
-
(churned.to_f / total_subscribers_start * 100).round(2)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def churned_subscriptions(period = DEFAULT_PERIOD)
|
|
165
|
-
Pay::Subscription
|
|
166
|
-
.includes(:customer)
|
|
167
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
168
|
-
.joins(:customer)
|
|
169
|
-
.where(status: ['canceled', 'ended'])
|
|
170
|
-
.where(ends_at: period.ago..Time.current)
|
|
211
|
+
calculate_churn_rate_for_period(period.ago, Time.current)
|
|
171
212
|
end
|
|
172
213
|
|
|
173
214
|
def calculate_churned_customers(period = DEFAULT_PERIOD)
|
|
174
|
-
|
|
215
|
+
calculate_churned_subscribers_in_period(period.ago, Time.current)
|
|
175
216
|
end
|
|
176
217
|
|
|
177
218
|
def calculate_churned_mrr(period = DEFAULT_PERIOD)
|
|
178
|
-
|
|
179
|
-
end_date = Time.current
|
|
180
|
-
|
|
181
|
-
Pay::Subscription
|
|
182
|
-
.includes(:customer)
|
|
183
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
184
|
-
.joins(:customer)
|
|
185
|
-
.where(status: ['canceled', 'ended'])
|
|
186
|
-
.where('pay_subscriptions.updated_at BETWEEN ? AND ?', start_date, end_date)
|
|
187
|
-
.sum do |subscription|
|
|
188
|
-
if subscription.ends_at && subscription.ends_at > end_date
|
|
189
|
-
# Subscription ends in the future, don't count it as churned yet
|
|
190
|
-
0
|
|
191
|
-
else
|
|
192
|
-
# Calculate prorated MRR if the subscription ended within the period
|
|
193
|
-
end_date = [subscription.ends_at, end_date].compact.min
|
|
194
|
-
days_in_period = (end_date - start_date).to_i
|
|
195
|
-
total_days = (subscription.current_period_end - subscription.current_period_start).to_i
|
|
196
|
-
prorated_days = [days_in_period, total_days].min
|
|
197
|
-
|
|
198
|
-
mrr = MrrCalculator.process_subscription(subscription)
|
|
199
|
-
(mrr.to_f * prorated_days / total_days).round
|
|
200
|
-
end
|
|
201
|
-
end
|
|
219
|
+
calculate_churned_mrr_in_period(period.ago, Time.current)
|
|
202
220
|
end
|
|
203
221
|
|
|
204
222
|
def calculate_new_mrr(period = DEFAULT_PERIOD)
|
|
205
|
-
|
|
206
|
-
end_date = Time.current
|
|
207
|
-
|
|
208
|
-
Pay::Subscription
|
|
209
|
-
.active
|
|
210
|
-
.includes(:customer)
|
|
211
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
212
|
-
.joins(:customer)
|
|
213
|
-
.where(created_at: start_date..end_date)
|
|
214
|
-
.where.not(status: ['trialing', 'paused'])
|
|
215
|
-
.sum do |subscription|
|
|
216
|
-
mrr = MrrCalculator.process_subscription(subscription)
|
|
217
|
-
days_in_period = (end_date - subscription.created_at).to_i
|
|
218
|
-
total_days = (subscription.current_period_end - subscription.current_period_start).to_i
|
|
219
|
-
prorated_days = [days_in_period, total_days].min
|
|
220
|
-
(mrr.to_f * prorated_days / total_days).round
|
|
221
|
-
end
|
|
223
|
+
calculate_new_mrr_in_period(period.ago, Time.current)
|
|
222
224
|
end
|
|
223
225
|
|
|
224
226
|
def calculate_revenue_in_period(period)
|
|
@@ -271,10 +273,7 @@ module Profitable
|
|
|
271
273
|
end
|
|
272
274
|
|
|
273
275
|
def calculate_new_subscribers(period)
|
|
274
|
-
|
|
275
|
-
.where(created_at: period.ago..Time.current)
|
|
276
|
-
.distinct
|
|
277
|
-
.count
|
|
276
|
+
calculate_new_subscribers_in_period(period.ago, Time.current)
|
|
278
277
|
end
|
|
279
278
|
|
|
280
279
|
def calculate_average_revenue_per_customer
|
|
@@ -284,10 +283,16 @@ module Profitable
|
|
|
284
283
|
end
|
|
285
284
|
|
|
286
285
|
def calculate_lifetime_value
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
# LTV = Monthly ARPU / Monthly Churn Rate
|
|
287
|
+
# where ARPU (Average Revenue Per User) = MRR / active subscribers
|
|
288
|
+
subscribers = calculate_active_subscribers
|
|
289
|
+
return 0 if subscribers.zero?
|
|
290
|
+
|
|
291
|
+
monthly_arpu = mrr.to_f / subscribers # in cents
|
|
292
|
+
churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05)
|
|
289
293
|
return 0 if churn_rate.zero?
|
|
290
|
-
|
|
294
|
+
|
|
295
|
+
(monthly_arpu / churn_rate).round # LTV in cents
|
|
291
296
|
end
|
|
292
297
|
|
|
293
298
|
def calculate_mrr_growth(period = DEFAULT_PERIOD)
|
|
@@ -308,16 +313,218 @@ module Profitable
|
|
|
308
313
|
end
|
|
309
314
|
|
|
310
315
|
def calculate_mrr_at(date)
|
|
316
|
+
# Find subscriptions that were active AT the given date:
|
|
317
|
+
# - Created before or on that date
|
|
318
|
+
# - Not ended before that date (ends_at is nil OR ends_at > date)
|
|
319
|
+
# - Not paused at that date
|
|
320
|
+
# - Not in trialing status (trials don't count as MRR)
|
|
321
|
+
subscriptions_with_processor(
|
|
322
|
+
Pay::Subscription
|
|
323
|
+
.where('pay_subscriptions.created_at <= ?', date)
|
|
324
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
|
|
325
|
+
.where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
|
|
326
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
327
|
+
).sum do |subscription|
|
|
328
|
+
MrrCalculator.process_subscription(subscription)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def calculate_period_data(period)
|
|
333
|
+
period_start = period.ago
|
|
334
|
+
period_end = Time.current
|
|
335
|
+
|
|
336
|
+
new_customers_count = actual_customers.where(created_at: period_start..period_end).count
|
|
337
|
+
churned_count = calculate_churned_subscribers_in_period(period_start, period_end)
|
|
338
|
+
new_mrr_val = calculate_new_mrr_in_period(period_start, period_end)
|
|
339
|
+
churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end)
|
|
340
|
+
revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount)
|
|
341
|
+
|
|
342
|
+
# Churn rate (reuses churned_count)
|
|
343
|
+
total_at_start = Pay::Subscription
|
|
344
|
+
.where('pay_subscriptions.created_at < ?', period_start)
|
|
345
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
|
|
346
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
347
|
+
.distinct
|
|
348
|
+
.count('customer_id')
|
|
349
|
+
churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
|
|
350
|
+
|
|
351
|
+
{
|
|
352
|
+
new_customers: NumericResult.new(new_customers_count, :integer),
|
|
353
|
+
churned_customers: NumericResult.new(churned_count, :integer),
|
|
354
|
+
churn: NumericResult.new(churn_rate, :percentage),
|
|
355
|
+
new_mrr: NumericResult.new(new_mrr_val),
|
|
356
|
+
churned_mrr: NumericResult.new(churned_mrr_val),
|
|
357
|
+
mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val),
|
|
358
|
+
revenue: NumericResult.new(revenue_val)
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Batched: loads all data in 5 queries then groups by month in Ruby
|
|
363
|
+
def calculate_monthly_summary(months_count)
|
|
364
|
+
overall_start = (months_count - 1).months.ago.beginning_of_month
|
|
365
|
+
overall_end = Time.current.end_of_month
|
|
366
|
+
|
|
367
|
+
# Bulk load all data for the full range
|
|
368
|
+
new_sub_records = Pay::Subscription
|
|
369
|
+
.where(created_at: overall_start..overall_end)
|
|
370
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
371
|
+
.pluck(:customer_id, :created_at)
|
|
372
|
+
|
|
373
|
+
churned_sub_records = Pay::Subscription
|
|
374
|
+
.where(status: CHURNED_STATUSES)
|
|
375
|
+
.where(ends_at: overall_start..overall_end)
|
|
376
|
+
.pluck(:customer_id, :ends_at)
|
|
377
|
+
|
|
378
|
+
new_mrr_subs = subscriptions_with_processor(
|
|
379
|
+
Pay::Subscription
|
|
380
|
+
.where(status: 'active')
|
|
381
|
+
.where(created_at: overall_start..overall_end)
|
|
382
|
+
).to_a
|
|
383
|
+
|
|
384
|
+
churned_mrr_subs = subscriptions_with_processor(
|
|
385
|
+
Pay::Subscription
|
|
386
|
+
.where(status: CHURNED_STATUSES)
|
|
387
|
+
.where(ends_at: overall_start..overall_end)
|
|
388
|
+
).to_a
|
|
389
|
+
|
|
390
|
+
churn_base_records = Pay::Subscription
|
|
391
|
+
.where('pay_subscriptions.created_at < ?', overall_end)
|
|
392
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start)
|
|
393
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
394
|
+
.pluck(:customer_id, :created_at, :ends_at)
|
|
395
|
+
|
|
396
|
+
# Group by month in Ruby
|
|
397
|
+
summary = []
|
|
398
|
+
(months_count - 1).downto(0) do |months_ago|
|
|
399
|
+
month_start = months_ago.months.ago.beginning_of_month
|
|
400
|
+
month_end = month_start.end_of_month
|
|
401
|
+
|
|
402
|
+
new_count = new_sub_records
|
|
403
|
+
.select { |_, created_at| created_at >= month_start && created_at <= month_end }
|
|
404
|
+
.map(&:first).uniq.count
|
|
405
|
+
|
|
406
|
+
churned_count = churned_sub_records
|
|
407
|
+
.select { |_, ends_at| ends_at >= month_start && ends_at <= month_end }
|
|
408
|
+
.map(&:first).uniq.count
|
|
409
|
+
|
|
410
|
+
new_mrr_amount = new_mrr_subs
|
|
411
|
+
.select { |s| s.created_at >= month_start && s.created_at <= month_end }
|
|
412
|
+
.sum { |s| MrrCalculator.process_subscription(s) }
|
|
413
|
+
|
|
414
|
+
churned_mrr_amount = churned_mrr_subs
|
|
415
|
+
.select { |s| s.ends_at >= month_start && s.ends_at <= month_end }
|
|
416
|
+
.sum { |s| MrrCalculator.process_subscription(s) }
|
|
417
|
+
|
|
418
|
+
total_at_start = churn_base_records
|
|
419
|
+
.select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) }
|
|
420
|
+
.map(&:first).uniq.count
|
|
421
|
+
|
|
422
|
+
churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
|
|
423
|
+
|
|
424
|
+
summary << {
|
|
425
|
+
month: month_start.strftime('%Y-%m'),
|
|
426
|
+
month_date: month_start,
|
|
427
|
+
new_subscribers: new_count,
|
|
428
|
+
churned_subscribers: churned_count,
|
|
429
|
+
net_subscribers: new_count - churned_count,
|
|
430
|
+
new_mrr: new_mrr_amount,
|
|
431
|
+
churned_mrr: churned_mrr_amount,
|
|
432
|
+
net_mrr: new_mrr_amount - churned_mrr_amount,
|
|
433
|
+
churn_rate: churn_rate
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
summary
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Batched: loads all data in 2 queries then groups by day in Ruby
|
|
441
|
+
def calculate_daily_summary(days_count)
|
|
442
|
+
overall_start = (days_count - 1).days.ago.beginning_of_day
|
|
443
|
+
overall_end = Time.current.end_of_day
|
|
444
|
+
|
|
445
|
+
new_sub_records = Pay::Subscription
|
|
446
|
+
.where(created_at: overall_start..overall_end)
|
|
447
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
448
|
+
.pluck(:customer_id, :created_at)
|
|
449
|
+
|
|
450
|
+
churned_sub_records = Pay::Subscription
|
|
451
|
+
.where(status: CHURNED_STATUSES)
|
|
452
|
+
.where(ends_at: overall_start..overall_end)
|
|
453
|
+
.pluck(:customer_id, :ends_at)
|
|
454
|
+
|
|
455
|
+
summary = []
|
|
456
|
+
(days_count - 1).downto(0) do |days_ago|
|
|
457
|
+
day_start = days_ago.days.ago.beginning_of_day
|
|
458
|
+
day_end = day_start.end_of_day
|
|
459
|
+
|
|
460
|
+
new_count = new_sub_records
|
|
461
|
+
.select { |_, created_at| created_at >= day_start && created_at <= day_end }
|
|
462
|
+
.map(&:first).uniq.count
|
|
463
|
+
|
|
464
|
+
churned_count = churned_sub_records
|
|
465
|
+
.select { |_, ends_at| ends_at >= day_start && ends_at <= day_end }
|
|
466
|
+
.map(&:first).uniq.count
|
|
467
|
+
|
|
468
|
+
summary << {
|
|
469
|
+
date: day_start.to_date,
|
|
470
|
+
new_subscribers: new_count,
|
|
471
|
+
churned_subscribers: churned_count
|
|
472
|
+
}
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
summary
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Consolidated methods that work with any date range
|
|
479
|
+
def calculate_new_subscribers_in_period(period_start, period_end)
|
|
480
|
+
Pay::Customer.joins(:subscriptions)
|
|
481
|
+
.where(pay_subscriptions: { created_at: period_start..period_end })
|
|
482
|
+
.where.not(pay_subscriptions: { status: EXCLUDED_STATUSES })
|
|
483
|
+
.distinct
|
|
484
|
+
.count
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def calculate_churned_subscribers_in_period(period_start, period_end)
|
|
311
488
|
Pay::Subscription
|
|
312
|
-
.
|
|
313
|
-
.where(
|
|
314
|
-
.
|
|
315
|
-
.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
489
|
+
.where(status: CHURNED_STATUSES)
|
|
490
|
+
.where(ends_at: period_start..period_end)
|
|
491
|
+
.distinct
|
|
492
|
+
.count('customer_id')
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def calculate_new_mrr_in_period(period_start, period_end)
|
|
496
|
+
subscriptions_with_processor(
|
|
497
|
+
Pay::Subscription
|
|
498
|
+
.where(status: 'active')
|
|
499
|
+
.where(created_at: period_start..period_end)
|
|
500
|
+
).sum do |subscription|
|
|
501
|
+
MrrCalculator.process_subscription(subscription)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def calculate_churned_mrr_in_period(period_start, period_end)
|
|
506
|
+
subscriptions_with_processor(
|
|
507
|
+
Pay::Subscription
|
|
508
|
+
.where(status: CHURNED_STATUSES)
|
|
509
|
+
.where(ends_at: period_start..period_end)
|
|
510
|
+
).sum do |subscription|
|
|
511
|
+
MrrCalculator.process_subscription(subscription)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def calculate_churn_rate_for_period(period_start, period_end)
|
|
516
|
+
# Count subscribers who were active AT the start of the period
|
|
517
|
+
total_subscribers_start = Pay::Subscription
|
|
518
|
+
.where('pay_subscriptions.created_at < ?', period_start)
|
|
519
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
|
|
520
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
521
|
+
.distinct
|
|
522
|
+
.count('customer_id')
|
|
523
|
+
|
|
524
|
+
churned = calculate_churned_subscribers_in_period(period_start, period_end)
|
|
525
|
+
return 0 if total_subscribers_start == 0
|
|
526
|
+
|
|
527
|
+
(churned.to_f / total_subscribers_start * 100).round(1)
|
|
321
528
|
end
|
|
322
529
|
|
|
323
530
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: profitable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 2026-02-10 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: pay
|
|
@@ -47,7 +46,11 @@ executables: []
|
|
|
47
46
|
extensions: []
|
|
48
47
|
extra_rdoc_files: []
|
|
49
48
|
files:
|
|
49
|
+
- ".simplecov"
|
|
50
|
+
- AGENTS.md
|
|
51
|
+
- Appraisals
|
|
50
52
|
- CHANGELOG.md
|
|
53
|
+
- CLAUDE.md
|
|
51
54
|
- LICENSE.txt
|
|
52
55
|
- README.md
|
|
53
56
|
- Rakefile
|
|
@@ -56,9 +59,17 @@ files:
|
|
|
56
59
|
- app/views/layouts/profitable/application.html.erb
|
|
57
60
|
- app/views/profitable/dashboard/index.html.erb
|
|
58
61
|
- config/routes.rb
|
|
62
|
+
- gemfiles/pay_10.0.gemfile
|
|
63
|
+
- gemfiles/pay_11.0.gemfile
|
|
64
|
+
- gemfiles/pay_7.3.gemfile
|
|
65
|
+
- gemfiles/pay_8.3.gemfile
|
|
66
|
+
- gemfiles/pay_9.0.gemfile
|
|
67
|
+
- gemfiles/rails_7.2.gemfile
|
|
68
|
+
- gemfiles/rails_8.1.gemfile
|
|
59
69
|
- lib/profitable.rb
|
|
60
70
|
- lib/profitable/engine.rb
|
|
61
71
|
- lib/profitable/error.rb
|
|
72
|
+
- lib/profitable/json_helpers.rb
|
|
62
73
|
- lib/profitable/mrr_calculator.rb
|
|
63
74
|
- lib/profitable/numeric_result.rb
|
|
64
75
|
- lib/profitable/processors/base.rb
|
|
@@ -76,8 +87,7 @@ metadata:
|
|
|
76
87
|
allowed_push_host: https://rubygems.org
|
|
77
88
|
homepage_uri: https://github.com/rameerez/profitable
|
|
78
89
|
source_code_uri: https://github.com/rameerez/profitable
|
|
79
|
-
changelog_uri: https://github.com/rameerez/profitable
|
|
80
|
-
post_install_message:
|
|
90
|
+
changelog_uri: https://github.com/rameerez/profitable/blob/main/CHANGELOG.md
|
|
81
91
|
rdoc_options: []
|
|
82
92
|
require_paths:
|
|
83
93
|
- lib
|
|
@@ -92,8 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
92
102
|
- !ruby/object:Gem::Version
|
|
93
103
|
version: '0'
|
|
94
104
|
requirements: []
|
|
95
|
-
rubygems_version: 3.
|
|
96
|
-
signing_key:
|
|
105
|
+
rubygems_version: 3.6.2
|
|
97
106
|
specification_version: 4
|
|
98
107
|
summary: Calculate the MRR, ARR, churn, LTV, ARPU, total revenue & est. valuation
|
|
99
108
|
of your `pay`-powered Rails SaaS
|