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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- data/README.md +244 -13
- data/app/controllers/profitable/dashboard_controller.rb +1 -0
- data/app/views/profitable/dashboard/index.html.erb +4 -0
- data/context7.json +4 -0
- data/lib/profitable/metrics.rb +664 -0
- data/lib/profitable/mrr_calculator.rb +23 -2
- data/lib/profitable/processors/braintree_processor.rb +3 -1
- data/lib/profitable/processors/paddle_billing_processor.rb +2 -1
- data/lib/profitable/processors/paddle_classic_processor.rb +2 -1
- data/lib/profitable/processors/stripe_processor.rb +4 -0
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +1 -517
- metadata +4 -2
|
@@ -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
|
-
.
|
|
13
|
-
.where
|
|
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
|