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