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.
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::Charge.where("(pay_charges.data ->> 'paid' IS NULL OR pay_charges.data ->> 'paid' != ?) AND pay_charges.amount > 0", 'false')
125
- .where("pay_charges.data ->> 'status' = ? OR pay_charges.data ->> 'status' IS NULL", 'succeeded')
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
- start_date = period.ago
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
- churned_subscriptions(period).distinct.count('customer_id')
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
- start_date = period.ago
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
- start_date = period.ago
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
- Pay::Customer.joins(:subscriptions)
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
- return 0 if total_customers.zero?
288
- churn_rate = churn.to_f / 100
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
- (average_revenue_per_customer.to_f / churn_rate).round
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
- .active
313
- .where('pay_subscriptions.created_at <= ?', date)
314
- .where.not(status: ['trialing', 'paused'])
315
- .includes(:customer)
316
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
317
- .joins(:customer)
318
- .sum do |subscription|
319
- MrrCalculator.process_subscription(subscription)
320
- end
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.2.3
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: 2024-09-01 00:00:00.000000000 Z
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.5.17
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