profitable 0.4.0 → 0.5.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.
@@ -0,0 +1,664 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profitable
4
+ # Pay exposes some processor-specific status variants beyond the core generic list.
5
+ # We normalize them into business-meaningful groups so current-state metrics,
6
+ # historical event metrics, and churn denominators all behave consistently.
7
+ TRIAL_SUBSCRIPTION_STATUSES = ['trialing', 'on_trial'].freeze
8
+ CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted'].freeze
9
+ NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid'].freeze
10
+
11
+ class << self
12
+ include ActionView::Helpers::NumberHelper
13
+ include Profitable::JsonHelpers
14
+
15
+ DEFAULT_PERIOD = 30.days
16
+ 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]
17
+
18
+ # Monthly Recurring Revenue (MRR) from subscriptions that are billable right now.
19
+ # This is a current recurring run-rate metric, useful for operating momentum
20
+ # and near-term subscription changes.
21
+ def mrr
22
+ NumericResult.new(MrrCalculator.calculate)
23
+ end
24
+
25
+ # Annual Recurring Revenue (ARR) based on the current recurring base.
26
+ # This is today's MRR annualized, not historical 12-month revenue.
27
+ def arr
28
+ NumericResult.new(calculate_arr)
29
+ end
30
+
31
+ def churn(in_the_last: DEFAULT_PERIOD)
32
+ NumericResult.new(calculate_churn(in_the_last), :percentage)
33
+ end
34
+
35
+ def all_time_revenue
36
+ NumericResult.new(calculate_all_time_revenue)
37
+ end
38
+
39
+ # Trailing twelve-month revenue reflects actual cash collected in the last year.
40
+ # It complements ARR, which annualizes the current recurring base.
41
+ def ttm_revenue
42
+ revenue_in_period(in_the_last: 12.months)
43
+ end
44
+
45
+ # Founder-friendly shorthand for trailing-twelve-month revenue.
46
+ # We keep the explicit ttm_revenue name as the canonical API because bare
47
+ # "TTM" is ambiguous in finance once profit metrics enter the picture.
48
+ def ttm
49
+ ttm_revenue
50
+ end
51
+
52
+ # Historical revenue collected over a rolling period.
53
+ # Unlike ARR, this is trailing actual revenue rather than a projection.
54
+ def revenue_in_period(in_the_last: DEFAULT_PERIOD)
55
+ NumericResult.new(calculate_revenue_in_period(in_the_last))
56
+ end
57
+
58
+ def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD)
59
+ NumericResult.new(calculate_recurring_revenue_in_period(in_the_last))
60
+ end
61
+
62
+ def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD)
63
+ NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage)
64
+ end
65
+
66
+ def revenue_run_rate(in_the_last: 30.days)
67
+ NumericResult.new(calculate_revenue_run_rate(in_the_last))
68
+ end
69
+
70
+ # Backwards-compatible ARR-multiple heuristic for a quick valuation estimate.
71
+ # This is intentionally simple and should not be treated as a market appraisal.
72
+ def estimated_valuation(multiplier = nil, at: nil, multiple: nil)
73
+ estimated_arr_valuation(multiplier, at:, multiple:)
74
+ end
75
+
76
+ def estimated_arr_valuation(multiplier = nil, at: nil, multiple: nil)
77
+ actual_multiplier = multiplier || at || multiple || 3
78
+ NumericResult.new(calculate_estimated_valuation_from(arr.to_i, actual_multiplier))
79
+ end
80
+
81
+ def estimated_ttm_revenue_valuation(multiplier = nil, at: nil, multiple: nil)
82
+ actual_multiplier = multiplier || at || multiple || 3
83
+ NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier))
84
+ end
85
+
86
+ def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days)
87
+ actual_multiplier = multiplier || at || multiple || 3
88
+ NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier))
89
+ end
90
+
91
+ # Customers who have actually monetized: either a paid charge or a subscription
92
+ # that has crossed into a billable state.
93
+ def total_customers
94
+ NumericResult.new(calculate_total_customers, :integer)
95
+ end
96
+
97
+ # Customers who have ever had a paid subscription. Trial-only subscriptions do not count.
98
+ def total_subscribers
99
+ NumericResult.new(calculate_total_subscribers, :integer)
100
+ end
101
+
102
+ # Customers with subscriptions that are billable right now.
103
+ # Excludes free trials, paused subscriptions, and churned subscriptions.
104
+ def active_subscribers
105
+ NumericResult.new(calculate_active_subscribers, :integer)
106
+ end
107
+
108
+ # First-time customers added in the period, based on first monetization date
109
+ # rather than signup date.
110
+ def new_customers(in_the_last: DEFAULT_PERIOD)
111
+ NumericResult.new(calculate_new_customers(in_the_last), :integer)
112
+ end
113
+
114
+ # Customers whose subscriptions first became billable in the period.
115
+ # Trial starts do not count until the trial ends.
116
+ def new_subscribers(in_the_last: DEFAULT_PERIOD)
117
+ NumericResult.new(calculate_new_subscribers(in_the_last), :integer)
118
+ end
119
+
120
+ def churned_customers(in_the_last: DEFAULT_PERIOD)
121
+ NumericResult.new(calculate_churned_customers(in_the_last), :integer)
122
+ end
123
+
124
+ # Full monthly value of subscriptions that became billable in the period.
125
+ # This is a flow metric, so it still counts subscriptions that churned later in the same window.
126
+ def new_mrr(in_the_last: DEFAULT_PERIOD)
127
+ NumericResult.new(calculate_new_mrr(in_the_last))
128
+ end
129
+
130
+ def churned_mrr(in_the_last: DEFAULT_PERIOD)
131
+ NumericResult.new(calculate_churned_mrr(in_the_last))
132
+ end
133
+
134
+ def average_revenue_per_customer
135
+ NumericResult.new(calculate_average_revenue_per_customer)
136
+ end
137
+
138
+ def lifetime_value
139
+ NumericResult.new(calculate_lifetime_value)
140
+ end
141
+
142
+ def mrr_growth(in_the_last: DEFAULT_PERIOD)
143
+ NumericResult.new(calculate_mrr_growth(in_the_last))
144
+ end
145
+
146
+ def mrr_growth_rate(in_the_last: DEFAULT_PERIOD)
147
+ NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage)
148
+ end
149
+
150
+ def time_to_next_mrr_milestone
151
+ current_mrr = (mrr.to_i) / 100 # Convert cents to dollars
152
+ return "Unable to calculate. No MRR yet." if current_mrr <= 0
153
+
154
+ next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr }
155
+ return "Congratulations! You've reached the highest milestone." unless next_milestone
156
+
157
+ monthly_growth_rate = calculate_mrr_growth_rate / 100
158
+ return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0
159
+
160
+ # Convert monthly growth rate to daily growth rate
161
+ daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1
162
+ return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0
163
+
164
+ # Calculate the number of days to reach the next milestone
165
+ days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil
166
+
167
+ target_date = Time.current + days_to_milestone.days
168
+
169
+ "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
170
+ end
171
+
172
+ def monthly_summary(months: 12)
173
+ calculate_monthly_summary(months)
174
+ end
175
+
176
+ def daily_summary(days: 30)
177
+ calculate_daily_summary(days)
178
+ end
179
+
180
+ def period_data(in_the_last: DEFAULT_PERIOD)
181
+ calculate_period_data(in_the_last)
182
+ end
183
+
184
+ private
185
+
186
+ # Helper to load subscriptions with processor info from customer
187
+ def subscriptions_with_processor(scope = Pay::Subscription.all)
188
+ scope
189
+ .includes(:customer)
190
+ .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
191
+ .joins(:customer)
192
+ end
193
+
194
+ # Business semantics: a subscription becomes "real" for subscriber / new MRR
195
+ # reporting when billing starts. For trialless subscriptions that is created_at;
196
+ # for trials it is trial_ends_at.
197
+ def subscription_became_billable_at_sql
198
+ 'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)'
199
+ end
200
+
201
+ # We intentionally do not reuse Pay::Subscription.active here.
202
+ # Pay's active scope is access-oriented and can include free-trial access,
203
+ # while profitable needs billable subscription semantics for metrics.
204
+ def subscription_is_billable_by(date, scope = Pay::Subscription.all)
205
+ scope
206
+ .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES)
207
+ .where(
208
+ "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))",
209
+ TRIAL_SUBSCRIPTION_STATUSES,
210
+ date
211
+ )
212
+ .where(
213
+ "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)",
214
+ CHURNED_STATUSES
215
+ )
216
+ .where(
217
+ "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)",
218
+ 'paused'
219
+ )
220
+ end
221
+
222
+ # Any subscription that has ever crossed into a paid/billable state,
223
+ # even if it later churned. This is used for "ever" style counts.
224
+ def ever_billable_subscription_scope(scope = Pay::Subscription.all)
225
+ subscription_is_billable_by(Time.current, scope)
226
+ .where("#{subscription_became_billable_at_sql} <= ?", Time.current)
227
+ end
228
+
229
+ # Subscriptions that were billable at a historical point in time.
230
+ # This powers MRR snapshots, churn denominators, and other period math.
231
+ def billable_subscription_scope_at(date, scope = Pay::Subscription.all)
232
+ subscription_is_billable_by(date, scope)
233
+ .where("#{subscription_became_billable_at_sql} <= ?", date)
234
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
235
+ .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
236
+ end
237
+
238
+ # Current billable subscriptions. A future ends_at or future pause start means
239
+ # the subscription is still billable today and should remain in MRR / ARR.
240
+ def current_billable_subscription_scope(scope = Pay::Subscription.all)
241
+ billable_subscription_scope_at(Time.current, scope)
242
+ end
243
+
244
+ # Historical "new subscriber" / "new MRR" event window.
245
+ # The event date is when billing starts, not when the subscription record is created.
246
+ def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all)
247
+ subscription_is_billable_by(period_end, scope)
248
+ .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end)
249
+ end
250
+
251
+ def subscription_became_billable_at(subscription)
252
+ subscription.trial_ends_at || subscription.created_at
253
+ end
254
+
255
+ def paid_charges
256
+ # Pay gem v10+ stores charge data in `object` column, older versions used `data`
257
+ # We check both columns for backwards compatibility using database-agnostic JSON extraction
258
+ #
259
+ # Performance note: The COALESCE pattern may prevent index usage on some databases.
260
+ # This is an acceptable tradeoff for backwards compatibility with Pay < 10.
261
+ # For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+
262
+ # where only the `object` column is used.
263
+
264
+ # Build JSON extraction SQL for both object and data columns
265
+ paid_object = json_extract('pay_charges.object', 'paid')
266
+ paid_data = json_extract('pay_charges.data', 'paid')
267
+ status_object = json_extract('pay_charges.object', 'status')
268
+ status_data = json_extract('pay_charges.data', 'status')
269
+
270
+ Pay::Charge
271
+ .where("pay_charges.amount > 0")
272
+ .where(<<~SQL.squish, 'false', 'succeeded')
273
+ (
274
+ (COALESCE(#{paid_object}, #{paid_data}) IS NULL
275
+ OR COALESCE(#{paid_object}, #{paid_data}) != ?)
276
+ )
277
+ AND
278
+ (
279
+ COALESCE(#{status_object}, #{status_data}) = ?
280
+ OR COALESCE(#{status_object}, #{status_data}) IS NULL
281
+ )
282
+ SQL
283
+ end
284
+
285
+ # Revenue metrics should reflect net cash collected, not gross billed amounts.
286
+ # When Pay stores refunded cents on the charge, subtract them from revenue.
287
+ def net_revenue(scope)
288
+ scope.sum(net_charge_amount_sql)
289
+ end
290
+
291
+ def net_charge_amount_sql
292
+ "pay_charges.amount - COALESCE(pay_charges.amount_refunded, 0)"
293
+ end
294
+
295
+ def calculate_all_time_revenue
296
+ net_revenue(paid_charges)
297
+ end
298
+
299
+ def calculate_arr
300
+ (mrr.to_f * 12).round
301
+ end
302
+
303
+ def calculate_estimated_valuation(multiplier = 3)
304
+ calculate_estimated_valuation_from(calculate_arr, multiplier)
305
+ end
306
+
307
+ def calculate_estimated_valuation_from(base_amount, multiplier = 3)
308
+ multiplier = parse_multiplier(multiplier)
309
+ (base_amount * multiplier).round
310
+ end
311
+
312
+ def parse_multiplier(input)
313
+ case input
314
+ when Numeric
315
+ input.to_f
316
+ when String
317
+ if input.end_with?('x')
318
+ input.chomp('x').to_f
319
+ else
320
+ input.to_f
321
+ end
322
+ else
323
+ 3.0 # Default multiplier if input is invalid
324
+ end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range
325
+ end
326
+
327
+ def calculate_churn(period = DEFAULT_PERIOD)
328
+ calculate_churn_rate_for_period(period.ago, Time.current)
329
+ end
330
+
331
+ def calculate_churned_customers(period = DEFAULT_PERIOD)
332
+ calculate_churned_subscribers_in_period(period.ago, Time.current)
333
+ end
334
+
335
+ def calculate_churned_mrr(period = DEFAULT_PERIOD)
336
+ calculate_churned_mrr_in_period(period.ago, Time.current)
337
+ end
338
+
339
+ def calculate_new_mrr(period = DEFAULT_PERIOD)
340
+ calculate_new_mrr_in_period(period.ago, Time.current)
341
+ end
342
+
343
+ def calculate_revenue_in_period(period)
344
+ net_revenue(paid_charges.where(created_at: period.ago..Time.current))
345
+ end
346
+
347
+ def calculate_revenue_run_rate(period)
348
+ return 0 if period.to_i <= 0
349
+
350
+ # TrustMRR-style revenue multiples are usually quoted against recent monthly
351
+ # revenue annualized, so we normalize to a 30-day month and multiply by 12.
352
+ monthly_revenue = calculate_revenue_in_period(period).to_f * (30.days.to_f / period.to_f)
353
+ (monthly_revenue * 12).round
354
+ end
355
+
356
+ def calculate_recurring_revenue_in_period(period)
357
+ net_revenue(
358
+ paid_charges
359
+ .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id')
360
+ .where(created_at: period.ago..Time.current)
361
+ )
362
+ end
363
+
364
+ def calculate_recurring_revenue_percentage(period)
365
+ total_revenue = calculate_revenue_in_period(period)
366
+ recurring_revenue = calculate_recurring_revenue_in_period(period)
367
+
368
+ return 0 if total_revenue.zero?
369
+
370
+ ((recurring_revenue.to_f / total_revenue) * 100).round(2)
371
+ end
372
+
373
+ def calculate_total_customers
374
+ actual_customers.count
375
+ end
376
+
377
+ def calculate_total_subscribers
378
+ ever_billable_subscription_scope.distinct.count(:customer_id)
379
+ end
380
+
381
+ def calculate_active_subscribers
382
+ current_billable_subscription_scope.distinct.count(:customer_id)
383
+ end
384
+
385
+ def actual_customers
386
+ # A "customer" here means a monetized customer, not just an account record.
387
+ # We therefore union paid one-off/charge customers with customers whose
388
+ # subscriptions have reached a billable state.
389
+ customers_with_paid_charges = Pay::Customer.where(id: paid_charges.select(:customer_id))
390
+ customers_with_billable_subscriptions = Pay::Customer.where(id: ever_billable_subscription_scope.select(:customer_id))
391
+
392
+ customers_with_paid_charges.or(customers_with_billable_subscriptions).distinct
393
+ end
394
+
395
+ def calculate_new_customers(period)
396
+ period_start = period.ago
397
+ period_end = Time.current
398
+
399
+ # "New customer" is defined by first monetization date.
400
+ # We intentionally do not use Pay::Customer.created_at because a user might
401
+ # sign up long before they ever pay or convert from trial.
402
+ first_charge_dates = paid_charges.group(:customer_id).minimum(:created_at)
403
+ first_subscription_dates = ever_billable_subscription_scope
404
+ .group(:customer_id)
405
+ .minimum(Arel.sql(subscription_became_billable_at_sql))
406
+
407
+ customer_ids = first_charge_dates.keys | first_subscription_dates.keys
408
+
409
+ customer_ids.count do |customer_id|
410
+ first_customer_date = [first_charge_dates[customer_id], first_subscription_dates[customer_id]].compact.min
411
+ first_customer_date && first_customer_date >= period_start && first_customer_date <= period_end
412
+ end
413
+ end
414
+
415
+ def calculate_new_subscribers(period)
416
+ calculate_new_subscribers_in_period(period.ago, Time.current)
417
+ end
418
+
419
+ def calculate_average_revenue_per_customer
420
+ paying_customers = calculate_total_customers
421
+ return 0 if paying_customers.zero?
422
+ (all_time_revenue.to_f / paying_customers).round
423
+ end
424
+
425
+ def calculate_lifetime_value
426
+ # LTV = Monthly ARPU / Monthly Churn Rate
427
+ # where ARPU (Average Revenue Per User) = MRR / active subscribers
428
+ subscribers = calculate_active_subscribers
429
+ return 0 if subscribers.zero?
430
+
431
+ monthly_arpu = mrr.to_f / subscribers # in cents
432
+ churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05)
433
+ return 0 if churn_rate.zero?
434
+
435
+ (monthly_arpu / churn_rate).round # LTV in cents
436
+ end
437
+
438
+ def calculate_mrr_growth(period = DEFAULT_PERIOD)
439
+ new_mrr = calculate_new_mrr(period)
440
+ churned_mrr = calculate_churned_mrr(period)
441
+ new_mrr - churned_mrr
442
+ end
443
+
444
+ def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
445
+ end_date = Time.current
446
+ start_date = end_date - period
447
+
448
+ start_mrr = calculate_mrr_at(start_date)
449
+ end_mrr = calculate_mrr_at(end_date)
450
+
451
+ return 0 if start_mrr == 0
452
+ ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2)
453
+ end
454
+
455
+ def calculate_mrr_at(date)
456
+ # Find subscriptions that were active AT the given date:
457
+ # - Started billing before or on that date
458
+ # - Not ended before that date (ends_at is nil OR ends_at > date)
459
+ # - Not paused at that date
460
+ # - Not still in a free trial at that date
461
+ subscriptions_with_processor(
462
+ billable_subscription_scope_at(date)
463
+ ).sum do |subscription|
464
+ MrrCalculator.process_subscription(subscription)
465
+ end
466
+ end
467
+
468
+ def calculate_period_data(period)
469
+ period_start = period.ago
470
+ period_end = Time.current
471
+
472
+ # Keep these values delegated to the same underlying helpers used by the
473
+ # public methods so the dashboard and direct API calls stay in lockstep.
474
+ new_customers_count = calculate_new_customers(period)
475
+ churned_count = calculate_churned_subscribers_in_period(period_start, period_end)
476
+ new_mrr_val = calculate_new_mrr_in_period(period_start, period_end)
477
+ churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end)
478
+ revenue_val = net_revenue(paid_charges.where(created_at: period_start..period_end))
479
+
480
+ # Churn rate (reuses churned_count)
481
+ total_at_start = billable_subscription_scope_at(period_start)
482
+ .distinct
483
+ .count('customer_id')
484
+ churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
485
+
486
+ {
487
+ new_customers: NumericResult.new(new_customers_count, :integer),
488
+ churned_customers: NumericResult.new(churned_count, :integer),
489
+ churn: NumericResult.new(churn_rate, :percentage),
490
+ new_mrr: NumericResult.new(new_mrr_val),
491
+ churned_mrr: NumericResult.new(churned_mrr_val),
492
+ mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val),
493
+ revenue: NumericResult.new(revenue_val)
494
+ }
495
+ end
496
+
497
+ # Batched: loads all data in 5 queries then groups by month in Ruby
498
+ def calculate_monthly_summary(months_count)
499
+ overall_start = (months_count - 1).months.ago.beginning_of_month
500
+ overall_end = Time.current.end_of_month
501
+
502
+ # Bulk load all data for the full range, then group in Ruby.
503
+ # This keeps the dashboard query count low while preserving the same
504
+ # billable-date semantics used by the single-metric helpers.
505
+ new_sub_records = Pay::Subscription
506
+ .merge(billable_subscription_events_in_period(overall_start, overall_end))
507
+ .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql))
508
+
509
+ churned_sub_records = Pay::Subscription
510
+ .where(status: CHURNED_STATUSES)
511
+ .where(ends_at: overall_start..overall_end)
512
+ .pluck(:customer_id, :ends_at)
513
+
514
+ new_mrr_subs = subscriptions_with_processor(
515
+ billable_subscription_events_in_period(overall_start, overall_end)
516
+ ).to_a
517
+
518
+ churned_mrr_subs = subscriptions_with_processor(
519
+ Pay::Subscription
520
+ .where(status: CHURNED_STATUSES)
521
+ .where(ends_at: overall_start..overall_end)
522
+ ).to_a
523
+
524
+ churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription)
525
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start)
526
+ .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at)
527
+
528
+ # Group by month in Ruby using billable-at and ends_at as the event dates,
529
+ # rather than raw subscription created_at.
530
+ summary = []
531
+ (months_count - 1).downto(0) do |months_ago|
532
+ month_start = months_ago.months.ago.beginning_of_month
533
+ month_end = month_start.end_of_month
534
+
535
+ new_count = new_sub_records
536
+ .select { |_, created_at| created_at >= month_start && created_at <= month_end }
537
+ .map(&:first).uniq.count
538
+
539
+ churned_count = churned_sub_records
540
+ .select { |_, ends_at| ends_at >= month_start && ends_at <= month_end }
541
+ .map(&:first).uniq.count
542
+
543
+ new_mrr_amount = new_mrr_subs
544
+ .select { |s| subscription_became_billable_at(s) >= month_start && subscription_became_billable_at(s) <= month_end }
545
+ .sum { |s| MrrCalculator.process_subscription(s) }
546
+
547
+ churned_mrr_amount = churned_mrr_subs
548
+ .select { |s| s.ends_at >= month_start && s.ends_at <= month_end }
549
+ .sum { |s| MrrCalculator.process_subscription(s) }
550
+
551
+ total_at_start = churn_base_records
552
+ .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) }
553
+ .map(&:first).uniq.count
554
+
555
+ churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
556
+
557
+ summary << {
558
+ month: month_start.strftime('%Y-%m'),
559
+ month_date: month_start,
560
+ new_subscribers: new_count,
561
+ churned_subscribers: churned_count,
562
+ net_subscribers: new_count - churned_count,
563
+ new_mrr: new_mrr_amount,
564
+ churned_mrr: churned_mrr_amount,
565
+ net_mrr: new_mrr_amount - churned_mrr_amount,
566
+ churn_rate: churn_rate
567
+ }
568
+ end
569
+
570
+ summary
571
+ end
572
+
573
+ # Batched: loads all data in 2 queries then groups by day in Ruby
574
+ def calculate_daily_summary(days_count)
575
+ overall_start = (days_count - 1).days.ago.beginning_of_day
576
+ overall_end = Time.current.end_of_day
577
+
578
+ # Daily summary intentionally uses the same "became billable" event date as
579
+ # new_subscribers/new_mrr, so trial starts do not appear as paid conversions.
580
+ new_sub_records = Pay::Subscription
581
+ .merge(billable_subscription_events_in_period(overall_start, overall_end))
582
+ .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql))
583
+
584
+ churned_sub_records = Pay::Subscription
585
+ .where(status: CHURNED_STATUSES)
586
+ .where(ends_at: overall_start..overall_end)
587
+ .pluck(:customer_id, :ends_at)
588
+
589
+ summary = []
590
+ (days_count - 1).downto(0) do |days_ago|
591
+ day_start = days_ago.days.ago.beginning_of_day
592
+ day_end = day_start.end_of_day
593
+
594
+ new_count = new_sub_records
595
+ .select { |_, created_at| created_at >= day_start && created_at <= day_end }
596
+ .map(&:first).uniq.count
597
+
598
+ churned_count = churned_sub_records
599
+ .select { |_, ends_at| ends_at >= day_start && ends_at <= day_end }
600
+ .map(&:first).uniq.count
601
+
602
+ summary << {
603
+ date: day_start.to_date,
604
+ new_subscribers: new_count,
605
+ churned_subscribers: churned_count
606
+ }
607
+ end
608
+
609
+ summary
610
+ end
611
+
612
+ # Consolidated methods that work with any date range
613
+ def calculate_new_subscribers_in_period(period_start, period_end)
614
+ billable_subscription_events_in_period(period_start, period_end)
615
+ .distinct
616
+ .count(:customer_id)
617
+ end
618
+
619
+ def calculate_churned_subscribers_in_period(period_start, period_end)
620
+ # Churn happens when access/billing actually ends, which Pay stores on ends_at.
621
+ Pay::Subscription
622
+ .where(status: CHURNED_STATUSES)
623
+ .where(ends_at: period_start..period_end)
624
+ .distinct
625
+ .count('customer_id')
626
+ end
627
+
628
+ def calculate_new_mrr_in_period(period_start, period_end)
629
+ # New MRR is the full fixed monthly value of subscriptions whose billing
630
+ # started in the window. It is not prorated, and it still counts if the
631
+ # subscription churns later in the same period.
632
+ subscriptions_with_processor(
633
+ billable_subscription_events_in_period(period_start, period_end)
634
+ ).sum do |subscription|
635
+ MrrCalculator.process_subscription(subscription)
636
+ end
637
+ end
638
+
639
+ def calculate_churned_mrr_in_period(period_start, period_end)
640
+ # Churned MRR is the full fixed monthly value being lost at churn time.
641
+ subscriptions_with_processor(
642
+ Pay::Subscription
643
+ .where(status: CHURNED_STATUSES)
644
+ .where(ends_at: period_start..period_end)
645
+ ).sum do |subscription|
646
+ MrrCalculator.process_subscription(subscription)
647
+ end
648
+ end
649
+
650
+ def calculate_churn_rate_for_period(period_start, period_end)
651
+ # Count subscribers who were billable at the start of the period.
652
+ # This keeps free trials and not-yet-paying subscriptions out of the denominator.
653
+ total_subscribers_start = billable_subscription_scope_at(period_start)
654
+ .distinct
655
+ .count('customer_id')
656
+
657
+ churned = calculate_churned_subscribers_in_period(period_start, period_end)
658
+ return 0 if total_subscribers_start == 0
659
+
660
+ (churned.to_f / total_subscribers_start * 100).round(1)
661
+ end
662
+
663
+ end
664
+ end
@@ -8,9 +8,28 @@ module Profitable
8
8
  class MrrCalculator
9
9
  def self.calculate
10
10
  total_mrr = 0
11
+
12
+ # Do not use Pay::Subscription.active here.
13
+ # Pay's active scope is designed for entitlement/access checks and can include
14
+ # free-trial access. MRR needs subscriptions that are billable right now.
11
15
  subscriptions = Pay::Subscription
12
- .active
13
- .where.not(status: Profitable::EXCLUDED_STATUSES)
16
+ .where.not(status: Profitable::NEVER_BILLABLE_SUBSCRIPTION_STATUSES)
17
+ .where(
18
+ "(pay_subscriptions.status NOT IN (?) OR (pay_subscriptions.trial_ends_at IS NOT NULL AND pay_subscriptions.trial_ends_at <= ?))",
19
+ Profitable::TRIAL_SUBSCRIPTION_STATUSES,
20
+ Time.current
21
+ )
22
+ .where(
23
+ "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)",
24
+ Profitable::CHURNED_STATUSES
25
+ )
26
+ .where(
27
+ "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)",
28
+ 'paused'
29
+ )
30
+ .where('COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at) <= ?', Time.current)
31
+ .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', Time.current)
32
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', Time.current)
14
33
  .includes(:customer)
15
34
  .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
16
35
  .joins(:customer)
@@ -50,6 +69,8 @@ module Profitable
50
69
  end
51
70
 
52
71
  def self.processor_for(processor_name)
72
+ # MRR parsing is only implemented for processors with explicit adapters below.
73
+ # Unknown processors safely fall back to Base and contribute zero until supported.
53
74
  case processor_name
54
75
  when 'stripe'
55
76
  Processors::StripeProcessor