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
|
@@ -8,11 +8,13 @@ module Profitable
|
|
|
8
8
|
amount = data['price']
|
|
9
9
|
return 0 if amount.nil?
|
|
10
10
|
|
|
11
|
+
# Some processor payloads provide amounts as strings, so coerce before
|
|
12
|
+
# multiplying by quantity to avoid string repetition bugs.
|
|
11
13
|
quantity = subscription.quantity || 1
|
|
12
14
|
interval = data['billing_period_unit']
|
|
13
15
|
interval_count = data['billing_period_frequency'] || 1
|
|
14
16
|
|
|
15
|
-
normalize_to_monthly(amount * quantity, interval, interval_count)
|
|
17
|
+
normalize_to_monthly(amount.to_f * quantity, interval, interval_count)
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
end
|
|
@@ -18,11 +18,12 @@ module Profitable
|
|
|
18
18
|
amount = price_data.dig('unit_price', 'amount')
|
|
19
19
|
next if amount.nil?
|
|
20
20
|
|
|
21
|
+
# Paddle can also serialize amounts as strings; coerce before applying quantity.
|
|
21
22
|
item_quantity = item['quantity'] || 1
|
|
22
23
|
interval = price_data.dig('billing_cycle', 'interval')
|
|
23
24
|
interval_count = price_data.dig('billing_cycle', 'frequency')
|
|
24
25
|
|
|
25
|
-
total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
|
|
26
|
+
total_mrr += normalize_to_monthly(amount.to_f * item_quantity, interval, interval_count)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
total_mrr
|
|
@@ -8,11 +8,12 @@ module Profitable
|
|
|
8
8
|
amount = data['recurring_price']
|
|
9
9
|
return 0 if amount.nil?
|
|
10
10
|
|
|
11
|
+
# Paddle Classic payloads may expose string amounts; coerce before quantity math.
|
|
11
12
|
quantity = subscription.quantity || 1
|
|
12
13
|
interval = data['recurring_interval']
|
|
13
14
|
interval_count = 1 # Paddle Classic doesn't have interval_count
|
|
14
15
|
|
|
15
|
-
normalize_to_monthly(amount * quantity, interval, interval_count)
|
|
16
|
+
normalize_to_monthly(amount.to_f * quantity, interval, interval_count)
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -18,6 +18,10 @@ module Profitable
|
|
|
18
18
|
price_data = item['price'] || item
|
|
19
19
|
next if price_data.nil?
|
|
20
20
|
|
|
21
|
+
# Metered items are usage-based, so they do not have a fixed run-rate that
|
|
22
|
+
# belongs in MRR/ARR style metrics. Keep only licensed recurring items here.
|
|
23
|
+
next if price_data.dig('recurring', 'usage_type') == 'metered'
|
|
24
|
+
|
|
21
25
|
amount = price_data['unit_amount']
|
|
22
26
|
next if amount.nil?
|
|
23
27
|
|
data/lib/profitable/version.rb
CHANGED
data/lib/profitable.rb
CHANGED
|
@@ -12,520 +12,4 @@ require "pay"
|
|
|
12
12
|
require "active_support/core_ext/numeric/conversions"
|
|
13
13
|
require "action_view"
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
# Subscription status constants (at module level so MrrCalculator can reference them)
|
|
17
|
-
EXCLUDED_STATUSES = ['trialing', 'paused'].freeze
|
|
18
|
-
CHURNED_STATUSES = ['canceled', 'ended'].freeze
|
|
19
|
-
|
|
20
|
-
class << self
|
|
21
|
-
include ActionView::Helpers::NumberHelper
|
|
22
|
-
include Profitable::JsonHelpers
|
|
23
|
-
|
|
24
|
-
DEFAULT_PERIOD = 30.days
|
|
25
|
-
MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000]
|
|
26
|
-
|
|
27
|
-
def mrr
|
|
28
|
-
NumericResult.new(MrrCalculator.calculate)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def arr
|
|
32
|
-
NumericResult.new(calculate_arr)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def churn(in_the_last: DEFAULT_PERIOD)
|
|
36
|
-
NumericResult.new(calculate_churn(in_the_last), :percentage)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def all_time_revenue
|
|
40
|
-
NumericResult.new(calculate_all_time_revenue)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def revenue_in_period(in_the_last: DEFAULT_PERIOD)
|
|
44
|
-
NumericResult.new(calculate_revenue_in_period(in_the_last))
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD)
|
|
48
|
-
NumericResult.new(calculate_recurring_revenue_in_period(in_the_last))
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD)
|
|
52
|
-
NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def estimated_valuation(multiplier = nil, at: nil, multiple: nil)
|
|
56
|
-
actual_multiplier = multiplier || at || multiple || 3
|
|
57
|
-
NumericResult.new(calculate_estimated_valuation(actual_multiplier))
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def total_customers
|
|
61
|
-
NumericResult.new(calculate_total_customers, :integer)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def total_subscribers
|
|
65
|
-
NumericResult.new(calculate_total_subscribers, :integer)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def active_subscribers
|
|
69
|
-
NumericResult.new(calculate_active_subscribers, :integer)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def new_customers(in_the_last: DEFAULT_PERIOD)
|
|
73
|
-
NumericResult.new(calculate_new_customers(in_the_last), :integer)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def new_subscribers(in_the_last: DEFAULT_PERIOD)
|
|
77
|
-
NumericResult.new(calculate_new_subscribers(in_the_last), :integer)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def churned_customers(in_the_last: DEFAULT_PERIOD)
|
|
81
|
-
NumericResult.new(calculate_churned_customers(in_the_last), :integer)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def new_mrr(in_the_last: DEFAULT_PERIOD)
|
|
85
|
-
NumericResult.new(calculate_new_mrr(in_the_last))
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def churned_mrr(in_the_last: DEFAULT_PERIOD)
|
|
89
|
-
NumericResult.new(calculate_churned_mrr(in_the_last))
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def average_revenue_per_customer
|
|
93
|
-
NumericResult.new(calculate_average_revenue_per_customer)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def lifetime_value
|
|
97
|
-
NumericResult.new(calculate_lifetime_value)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def mrr_growth(in_the_last: DEFAULT_PERIOD)
|
|
101
|
-
NumericResult.new(calculate_mrr_growth(in_the_last))
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def mrr_growth_rate(in_the_last: DEFAULT_PERIOD)
|
|
105
|
-
NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def time_to_next_mrr_milestone
|
|
109
|
-
current_mrr = (mrr.to_i) / 100 # Convert cents to dollars
|
|
110
|
-
return "Unable to calculate. No MRR yet." if current_mrr <= 0
|
|
111
|
-
|
|
112
|
-
next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr }
|
|
113
|
-
return "Congratulations! You've reached the highest milestone." unless next_milestone
|
|
114
|
-
|
|
115
|
-
monthly_growth_rate = calculate_mrr_growth_rate / 100
|
|
116
|
-
return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0
|
|
117
|
-
|
|
118
|
-
# Convert monthly growth rate to daily growth rate
|
|
119
|
-
daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1
|
|
120
|
-
return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0
|
|
121
|
-
|
|
122
|
-
# Calculate the number of days to reach the next milestone
|
|
123
|
-
days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil
|
|
124
|
-
|
|
125
|
-
target_date = Time.current + days_to_milestone.days
|
|
126
|
-
|
|
127
|
-
"#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def monthly_summary(months: 12)
|
|
131
|
-
calculate_monthly_summary(months)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def daily_summary(days: 30)
|
|
135
|
-
calculate_daily_summary(days)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def period_data(in_the_last: DEFAULT_PERIOD)
|
|
139
|
-
calculate_period_data(in_the_last)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
private
|
|
143
|
-
|
|
144
|
-
# Helper to load subscriptions with processor info from customer
|
|
145
|
-
def subscriptions_with_processor(scope = Pay::Subscription.all)
|
|
146
|
-
scope
|
|
147
|
-
.includes(:customer)
|
|
148
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
149
|
-
.joins(:customer)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def paid_charges
|
|
153
|
-
# Pay gem v10+ stores charge data in `object` column, older versions used `data`
|
|
154
|
-
# We check both columns for backwards compatibility using database-agnostic JSON extraction
|
|
155
|
-
#
|
|
156
|
-
# Performance note: The COALESCE pattern may prevent index usage on some databases.
|
|
157
|
-
# This is an acceptable tradeoff for backwards compatibility with Pay < 10.
|
|
158
|
-
# For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+
|
|
159
|
-
# where only the `object` column is used.
|
|
160
|
-
|
|
161
|
-
# Build JSON extraction SQL for both object and data columns
|
|
162
|
-
paid_object = json_extract('pay_charges.object', 'paid')
|
|
163
|
-
paid_data = json_extract('pay_charges.data', 'paid')
|
|
164
|
-
status_object = json_extract('pay_charges.object', 'status')
|
|
165
|
-
status_data = json_extract('pay_charges.data', 'status')
|
|
166
|
-
|
|
167
|
-
Pay::Charge
|
|
168
|
-
.where("pay_charges.amount > 0")
|
|
169
|
-
.where(<<~SQL.squish, 'false', 'succeeded')
|
|
170
|
-
(
|
|
171
|
-
(COALESCE(#{paid_object}, #{paid_data}) IS NULL
|
|
172
|
-
OR COALESCE(#{paid_object}, #{paid_data}) != ?)
|
|
173
|
-
)
|
|
174
|
-
AND
|
|
175
|
-
(
|
|
176
|
-
COALESCE(#{status_object}, #{status_data}) = ?
|
|
177
|
-
OR COALESCE(#{status_object}, #{status_data}) IS NULL
|
|
178
|
-
)
|
|
179
|
-
SQL
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def calculate_all_time_revenue
|
|
183
|
-
paid_charges.sum(:amount)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def calculate_arr
|
|
187
|
-
(mrr.to_f * 12).round
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def calculate_estimated_valuation(multiplier = 3)
|
|
191
|
-
multiplier = parse_multiplier(multiplier)
|
|
192
|
-
(calculate_arr * multiplier).round
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def parse_multiplier(input)
|
|
196
|
-
case input
|
|
197
|
-
when Numeric
|
|
198
|
-
input.to_f
|
|
199
|
-
when String
|
|
200
|
-
if input.end_with?('x')
|
|
201
|
-
input.chomp('x').to_f
|
|
202
|
-
else
|
|
203
|
-
input.to_f
|
|
204
|
-
end
|
|
205
|
-
else
|
|
206
|
-
3.0 # Default multiplier if input is invalid
|
|
207
|
-
end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def calculate_churn(period = DEFAULT_PERIOD)
|
|
211
|
-
calculate_churn_rate_for_period(period.ago, Time.current)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def calculate_churned_customers(period = DEFAULT_PERIOD)
|
|
215
|
-
calculate_churned_subscribers_in_period(period.ago, Time.current)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def calculate_churned_mrr(period = DEFAULT_PERIOD)
|
|
219
|
-
calculate_churned_mrr_in_period(period.ago, Time.current)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def calculate_new_mrr(period = DEFAULT_PERIOD)
|
|
223
|
-
calculate_new_mrr_in_period(period.ago, Time.current)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def calculate_revenue_in_period(period)
|
|
227
|
-
paid_charges.where(created_at: period.ago..Time.current).sum(:amount)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def calculate_recurring_revenue_in_period(period)
|
|
231
|
-
paid_charges
|
|
232
|
-
.joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id')
|
|
233
|
-
.where(created_at: period.ago..Time.current)
|
|
234
|
-
.sum(:amount)
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def calculate_recurring_revenue_percentage(period)
|
|
238
|
-
total_revenue = calculate_revenue_in_period(period)
|
|
239
|
-
recurring_revenue = calculate_recurring_revenue_in_period(period)
|
|
240
|
-
|
|
241
|
-
return 0 if total_revenue.zero?
|
|
242
|
-
|
|
243
|
-
((recurring_revenue.to_f / total_revenue) * 100).round(2)
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def calculate_total_customers
|
|
247
|
-
Pay::Customer.joins(:charges)
|
|
248
|
-
.merge(paid_charges)
|
|
249
|
-
.distinct
|
|
250
|
-
.count
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def calculate_total_subscribers
|
|
254
|
-
Pay::Customer.joins(:subscriptions).distinct.count
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def calculate_active_subscribers
|
|
258
|
-
Pay::Customer.joins(:subscriptions)
|
|
259
|
-
.where(pay_subscriptions: { status: 'active' })
|
|
260
|
-
.distinct
|
|
261
|
-
.count
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def actual_customers
|
|
265
|
-
Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id")
|
|
266
|
-
.joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id")
|
|
267
|
-
.where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0")
|
|
268
|
-
.distinct
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def calculate_new_customers(period)
|
|
272
|
-
actual_customers.where(created_at: period.ago..Time.current).count
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def calculate_new_subscribers(period)
|
|
276
|
-
calculate_new_subscribers_in_period(period.ago, Time.current)
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
def calculate_average_revenue_per_customer
|
|
280
|
-
paying_customers = calculate_total_customers
|
|
281
|
-
return 0 if paying_customers.zero?
|
|
282
|
-
(all_time_revenue.to_f / paying_customers).round
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def calculate_lifetime_value
|
|
286
|
-
# LTV = Monthly ARPU / Monthly Churn Rate
|
|
287
|
-
# where ARPU (Average Revenue Per User) = MRR / active subscribers
|
|
288
|
-
subscribers = calculate_active_subscribers
|
|
289
|
-
return 0 if subscribers.zero?
|
|
290
|
-
|
|
291
|
-
monthly_arpu = mrr.to_f / subscribers # in cents
|
|
292
|
-
churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05)
|
|
293
|
-
return 0 if churn_rate.zero?
|
|
294
|
-
|
|
295
|
-
(monthly_arpu / churn_rate).round # LTV in cents
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def calculate_mrr_growth(period = DEFAULT_PERIOD)
|
|
299
|
-
new_mrr = calculate_new_mrr(period)
|
|
300
|
-
churned_mrr = calculate_churned_mrr(period)
|
|
301
|
-
new_mrr - churned_mrr
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
|
|
305
|
-
end_date = Time.current
|
|
306
|
-
start_date = end_date - period
|
|
307
|
-
|
|
308
|
-
start_mrr = calculate_mrr_at(start_date)
|
|
309
|
-
end_mrr = calculate_mrr_at(end_date)
|
|
310
|
-
|
|
311
|
-
return 0 if start_mrr == 0
|
|
312
|
-
((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def calculate_mrr_at(date)
|
|
316
|
-
# Find subscriptions that were active AT the given date:
|
|
317
|
-
# - Created before or on that date
|
|
318
|
-
# - Not ended before that date (ends_at is nil OR ends_at > date)
|
|
319
|
-
# - Not paused at that date
|
|
320
|
-
# - Not in trialing status (trials don't count as MRR)
|
|
321
|
-
subscriptions_with_processor(
|
|
322
|
-
Pay::Subscription
|
|
323
|
-
.where('pay_subscriptions.created_at <= ?', date)
|
|
324
|
-
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
|
|
325
|
-
.where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
|
|
326
|
-
.where.not(status: EXCLUDED_STATUSES)
|
|
327
|
-
).sum do |subscription|
|
|
328
|
-
MrrCalculator.process_subscription(subscription)
|
|
329
|
-
end
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def calculate_period_data(period)
|
|
333
|
-
period_start = period.ago
|
|
334
|
-
period_end = Time.current
|
|
335
|
-
|
|
336
|
-
new_customers_count = actual_customers.where(created_at: period_start..period_end).count
|
|
337
|
-
churned_count = calculate_churned_subscribers_in_period(period_start, period_end)
|
|
338
|
-
new_mrr_val = calculate_new_mrr_in_period(period_start, period_end)
|
|
339
|
-
churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end)
|
|
340
|
-
revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount)
|
|
341
|
-
|
|
342
|
-
# Churn rate (reuses churned_count)
|
|
343
|
-
total_at_start = Pay::Subscription
|
|
344
|
-
.where('pay_subscriptions.created_at < ?', period_start)
|
|
345
|
-
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
|
|
346
|
-
.where.not(status: EXCLUDED_STATUSES)
|
|
347
|
-
.distinct
|
|
348
|
-
.count('customer_id')
|
|
349
|
-
churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
|
|
350
|
-
|
|
351
|
-
{
|
|
352
|
-
new_customers: NumericResult.new(new_customers_count, :integer),
|
|
353
|
-
churned_customers: NumericResult.new(churned_count, :integer),
|
|
354
|
-
churn: NumericResult.new(churn_rate, :percentage),
|
|
355
|
-
new_mrr: NumericResult.new(new_mrr_val),
|
|
356
|
-
churned_mrr: NumericResult.new(churned_mrr_val),
|
|
357
|
-
mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val),
|
|
358
|
-
revenue: NumericResult.new(revenue_val)
|
|
359
|
-
}
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
# Batched: loads all data in 5 queries then groups by month in Ruby
|
|
363
|
-
def calculate_monthly_summary(months_count)
|
|
364
|
-
overall_start = (months_count - 1).months.ago.beginning_of_month
|
|
365
|
-
overall_end = Time.current.end_of_month
|
|
366
|
-
|
|
367
|
-
# Bulk load all data for the full range
|
|
368
|
-
new_sub_records = Pay::Subscription
|
|
369
|
-
.where(created_at: overall_start..overall_end)
|
|
370
|
-
.where.not(status: EXCLUDED_STATUSES)
|
|
371
|
-
.pluck(:customer_id, :created_at)
|
|
372
|
-
|
|
373
|
-
churned_sub_records = Pay::Subscription
|
|
374
|
-
.where(status: CHURNED_STATUSES)
|
|
375
|
-
.where(ends_at: overall_start..overall_end)
|
|
376
|
-
.pluck(:customer_id, :ends_at)
|
|
377
|
-
|
|
378
|
-
new_mrr_subs = subscriptions_with_processor(
|
|
379
|
-
Pay::Subscription
|
|
380
|
-
.where(status: 'active')
|
|
381
|
-
.where(created_at: overall_start..overall_end)
|
|
382
|
-
).to_a
|
|
383
|
-
|
|
384
|
-
churned_mrr_subs = subscriptions_with_processor(
|
|
385
|
-
Pay::Subscription
|
|
386
|
-
.where(status: CHURNED_STATUSES)
|
|
387
|
-
.where(ends_at: overall_start..overall_end)
|
|
388
|
-
).to_a
|
|
389
|
-
|
|
390
|
-
churn_base_records = Pay::Subscription
|
|
391
|
-
.where('pay_subscriptions.created_at < ?', overall_end)
|
|
392
|
-
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start)
|
|
393
|
-
.where.not(status: EXCLUDED_STATUSES)
|
|
394
|
-
.pluck(:customer_id, :created_at, :ends_at)
|
|
395
|
-
|
|
396
|
-
# Group by month in Ruby
|
|
397
|
-
summary = []
|
|
398
|
-
(months_count - 1).downto(0) do |months_ago|
|
|
399
|
-
month_start = months_ago.months.ago.beginning_of_month
|
|
400
|
-
month_end = month_start.end_of_month
|
|
401
|
-
|
|
402
|
-
new_count = new_sub_records
|
|
403
|
-
.select { |_, created_at| created_at >= month_start && created_at <= month_end }
|
|
404
|
-
.map(&:first).uniq.count
|
|
405
|
-
|
|
406
|
-
churned_count = churned_sub_records
|
|
407
|
-
.select { |_, ends_at| ends_at >= month_start && ends_at <= month_end }
|
|
408
|
-
.map(&:first).uniq.count
|
|
409
|
-
|
|
410
|
-
new_mrr_amount = new_mrr_subs
|
|
411
|
-
.select { |s| s.created_at >= month_start && s.created_at <= month_end }
|
|
412
|
-
.sum { |s| MrrCalculator.process_subscription(s) }
|
|
413
|
-
|
|
414
|
-
churned_mrr_amount = churned_mrr_subs
|
|
415
|
-
.select { |s| s.ends_at >= month_start && s.ends_at <= month_end }
|
|
416
|
-
.sum { |s| MrrCalculator.process_subscription(s) }
|
|
417
|
-
|
|
418
|
-
total_at_start = churn_base_records
|
|
419
|
-
.select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) }
|
|
420
|
-
.map(&:first).uniq.count
|
|
421
|
-
|
|
422
|
-
churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
|
|
423
|
-
|
|
424
|
-
summary << {
|
|
425
|
-
month: month_start.strftime('%Y-%m'),
|
|
426
|
-
month_date: month_start,
|
|
427
|
-
new_subscribers: new_count,
|
|
428
|
-
churned_subscribers: churned_count,
|
|
429
|
-
net_subscribers: new_count - churned_count,
|
|
430
|
-
new_mrr: new_mrr_amount,
|
|
431
|
-
churned_mrr: churned_mrr_amount,
|
|
432
|
-
net_mrr: new_mrr_amount - churned_mrr_amount,
|
|
433
|
-
churn_rate: churn_rate
|
|
434
|
-
}
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
summary
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
# Batched: loads all data in 2 queries then groups by day in Ruby
|
|
441
|
-
def calculate_daily_summary(days_count)
|
|
442
|
-
overall_start = (days_count - 1).days.ago.beginning_of_day
|
|
443
|
-
overall_end = Time.current.end_of_day
|
|
444
|
-
|
|
445
|
-
new_sub_records = Pay::Subscription
|
|
446
|
-
.where(created_at: overall_start..overall_end)
|
|
447
|
-
.where.not(status: EXCLUDED_STATUSES)
|
|
448
|
-
.pluck(:customer_id, :created_at)
|
|
449
|
-
|
|
450
|
-
churned_sub_records = Pay::Subscription
|
|
451
|
-
.where(status: CHURNED_STATUSES)
|
|
452
|
-
.where(ends_at: overall_start..overall_end)
|
|
453
|
-
.pluck(:customer_id, :ends_at)
|
|
454
|
-
|
|
455
|
-
summary = []
|
|
456
|
-
(days_count - 1).downto(0) do |days_ago|
|
|
457
|
-
day_start = days_ago.days.ago.beginning_of_day
|
|
458
|
-
day_end = day_start.end_of_day
|
|
459
|
-
|
|
460
|
-
new_count = new_sub_records
|
|
461
|
-
.select { |_, created_at| created_at >= day_start && created_at <= day_end }
|
|
462
|
-
.map(&:first).uniq.count
|
|
463
|
-
|
|
464
|
-
churned_count = churned_sub_records
|
|
465
|
-
.select { |_, ends_at| ends_at >= day_start && ends_at <= day_end }
|
|
466
|
-
.map(&:first).uniq.count
|
|
467
|
-
|
|
468
|
-
summary << {
|
|
469
|
-
date: day_start.to_date,
|
|
470
|
-
new_subscribers: new_count,
|
|
471
|
-
churned_subscribers: churned_count
|
|
472
|
-
}
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
summary
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# Consolidated methods that work with any date range
|
|
479
|
-
def calculate_new_subscribers_in_period(period_start, period_end)
|
|
480
|
-
Pay::Customer.joins(:subscriptions)
|
|
481
|
-
.where(pay_subscriptions: { created_at: period_start..period_end })
|
|
482
|
-
.where.not(pay_subscriptions: { status: EXCLUDED_STATUSES })
|
|
483
|
-
.distinct
|
|
484
|
-
.count
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
def calculate_churned_subscribers_in_period(period_start, period_end)
|
|
488
|
-
Pay::Subscription
|
|
489
|
-
.where(status: CHURNED_STATUSES)
|
|
490
|
-
.where(ends_at: period_start..period_end)
|
|
491
|
-
.distinct
|
|
492
|
-
.count('customer_id')
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
def calculate_new_mrr_in_period(period_start, period_end)
|
|
496
|
-
subscriptions_with_processor(
|
|
497
|
-
Pay::Subscription
|
|
498
|
-
.where(status: 'active')
|
|
499
|
-
.where(created_at: period_start..period_end)
|
|
500
|
-
).sum do |subscription|
|
|
501
|
-
MrrCalculator.process_subscription(subscription)
|
|
502
|
-
end
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
def calculate_churned_mrr_in_period(period_start, period_end)
|
|
506
|
-
subscriptions_with_processor(
|
|
507
|
-
Pay::Subscription
|
|
508
|
-
.where(status: CHURNED_STATUSES)
|
|
509
|
-
.where(ends_at: period_start..period_end)
|
|
510
|
-
).sum do |subscription|
|
|
511
|
-
MrrCalculator.process_subscription(subscription)
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
def calculate_churn_rate_for_period(period_start, period_end)
|
|
516
|
-
# Count subscribers who were active AT the start of the period
|
|
517
|
-
total_subscribers_start = Pay::Subscription
|
|
518
|
-
.where('pay_subscriptions.created_at < ?', period_start)
|
|
519
|
-
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
|
|
520
|
-
.where.not(status: EXCLUDED_STATUSES)
|
|
521
|
-
.distinct
|
|
522
|
-
.count('customer_id')
|
|
523
|
-
|
|
524
|
-
churned = calculate_churned_subscribers_in_period(period_start, period_end)
|
|
525
|
-
return 0 if total_subscribers_start == 0
|
|
526
|
-
|
|
527
|
-
(churned.to_f / total_subscribers_start * 100).round(1)
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
end
|
|
531
|
-
end
|
|
15
|
+
require_relative "profitable/metrics"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: profitable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-03-19 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- app/views/layouts/profitable/application.html.erb
|
|
60
60
|
- app/views/profitable/dashboard/index.html.erb
|
|
61
61
|
- config/routes.rb
|
|
62
|
+
- context7.json
|
|
62
63
|
- gemfiles/pay_10.0.gemfile
|
|
63
64
|
- gemfiles/pay_11.0.gemfile
|
|
64
65
|
- gemfiles/pay_7.3.gemfile
|
|
@@ -70,6 +71,7 @@ files:
|
|
|
70
71
|
- lib/profitable/engine.rb
|
|
71
72
|
- lib/profitable/error.rb
|
|
72
73
|
- lib/profitable/json_helpers.rb
|
|
74
|
+
- lib/profitable/metrics.rb
|
|
73
75
|
- lib/profitable/mrr_calculator.rb
|
|
74
76
|
- lib/profitable/numeric_result.rb
|
|
75
77
|
- lib/profitable/processors/base.rb
|