profitable 0.3.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.
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profitable
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/profitable.rb CHANGED
@@ -12,354 +12,4 @@ require "pay"
12
12
  require "active_support/core_ext/numeric/conversions"
13
13
  require "action_view"
14
14
 
15
- module Profitable
16
- class << self
17
- include ActionView::Helpers::NumberHelper
18
- include Profitable::JsonHelpers
19
-
20
- DEFAULT_PERIOD = 30.days
21
- 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]
22
-
23
- def mrr
24
- NumericResult.new(MrrCalculator.calculate)
25
- end
26
-
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
- def revenue_in_period(in_the_last: DEFAULT_PERIOD)
40
- NumericResult.new(calculate_revenue_in_period(in_the_last))
41
- end
42
-
43
- def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD)
44
- NumericResult.new(calculate_recurring_revenue_in_period(in_the_last))
45
- end
46
-
47
- def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD)
48
- NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage)
49
- end
50
-
51
- def estimated_valuation(multiplier = nil, at: nil, multiple: nil)
52
- actual_multiplier = multiplier || at || multiple || 3
53
- NumericResult.new(calculate_estimated_valuation(actual_multiplier))
54
- end
55
-
56
- def total_customers
57
- NumericResult.new(calculate_total_customers, :integer)
58
- end
59
-
60
- def total_subscribers
61
- NumericResult.new(calculate_total_subscribers, :integer)
62
- end
63
-
64
- def active_subscribers
65
- NumericResult.new(calculate_active_subscribers, :integer)
66
- end
67
-
68
- def new_customers(in_the_last: DEFAULT_PERIOD)
69
- NumericResult.new(calculate_new_customers(in_the_last), :integer)
70
- end
71
-
72
- def new_subscribers(in_the_last: DEFAULT_PERIOD)
73
- NumericResult.new(calculate_new_subscribers(in_the_last), :integer)
74
- end
75
-
76
- def churned_customers(in_the_last: DEFAULT_PERIOD)
77
- NumericResult.new(calculate_churned_customers(in_the_last), :integer)
78
- end
79
-
80
- def new_mrr(in_the_last: DEFAULT_PERIOD)
81
- NumericResult.new(calculate_new_mrr(in_the_last))
82
- end
83
-
84
- def churned_mrr(in_the_last: DEFAULT_PERIOD)
85
- NumericResult.new(calculate_churned_mrr(in_the_last))
86
- end
87
-
88
- def average_revenue_per_customer
89
- NumericResult.new(calculate_average_revenue_per_customer)
90
- end
91
-
92
- def lifetime_value
93
- NumericResult.new(calculate_lifetime_value)
94
- end
95
-
96
- def mrr_growth(in_the_last: DEFAULT_PERIOD)
97
- NumericResult.new(calculate_mrr_growth(in_the_last))
98
- end
99
-
100
- def mrr_growth_rate(in_the_last: DEFAULT_PERIOD)
101
- NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage)
102
- end
103
-
104
- def time_to_next_mrr_milestone
105
- current_mrr = (mrr.to_i) / 100 # Convert cents to dollars
106
- return "Unable to calculate. No MRR yet." if current_mrr <= 0
107
-
108
- next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr }
109
- return "Congratulations! You've reached the highest milestone." unless next_milestone
110
-
111
- monthly_growth_rate = calculate_mrr_growth_rate / 100
112
- return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0
113
-
114
- # Convert monthly growth rate to daily growth rate
115
- daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1
116
- return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0
117
-
118
- # Calculate the number of days to reach the next milestone
119
- days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil
120
-
121
- target_date = Time.current + days_to_milestone.days
122
-
123
- "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
124
- end
125
-
126
- private
127
-
128
- def paid_charges
129
- # Pay gem v10+ stores charge data in `object` column, older versions used `data`
130
- # We check both columns for backwards compatibility using database-agnostic JSON extraction
131
- #
132
- # Performance note: The COALESCE pattern may prevent index usage on some databases.
133
- # This is an acceptable tradeoff for backwards compatibility with Pay < 10.
134
- # For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+
135
- # where only the `object` column is used.
136
-
137
- # Build JSON extraction SQL for both object and data columns
138
- paid_object = json_extract('pay_charges.object', 'paid')
139
- paid_data = json_extract('pay_charges.data', 'paid')
140
- status_object = json_extract('pay_charges.object', 'status')
141
- status_data = json_extract('pay_charges.data', 'status')
142
-
143
- Pay::Charge
144
- .where("pay_charges.amount > 0")
145
- .where(<<~SQL.squish, 'false', 'succeeded')
146
- (
147
- (COALESCE(#{paid_object}, #{paid_data}) IS NULL
148
- OR COALESCE(#{paid_object}, #{paid_data}) != ?)
149
- )
150
- AND
151
- (
152
- COALESCE(#{status_object}, #{status_data}) = ?
153
- OR COALESCE(#{status_object}, #{status_data}) IS NULL
154
- )
155
- SQL
156
- end
157
-
158
- def calculate_all_time_revenue
159
- paid_charges.sum(:amount)
160
- end
161
-
162
- def calculate_arr
163
- (mrr.to_f * 12).round
164
- end
165
-
166
- def calculate_estimated_valuation(multiplier = 3)
167
- multiplier = parse_multiplier(multiplier)
168
- (calculate_arr * multiplier).round
169
- end
170
-
171
- def parse_multiplier(input)
172
- case input
173
- when Numeric
174
- input.to_f
175
- when String
176
- if input.end_with?('x')
177
- input.chomp('x').to_f
178
- else
179
- input.to_f
180
- end
181
- else
182
- 3.0 # Default multiplier if input is invalid
183
- end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range
184
- end
185
-
186
- def calculate_churn(period = DEFAULT_PERIOD)
187
- start_date = period.ago
188
-
189
- # Count subscribers who were active AT the start of the period
190
- # (not just currently active, but active at that historical point)
191
- total_subscribers_start = Pay::Subscription
192
- .where('pay_subscriptions.created_at < ?', start_date)
193
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', start_date)
194
- .where.not(status: ['trialing', 'paused'])
195
- .distinct
196
- .count('customer_id')
197
-
198
- churned = calculate_churned_customers(period)
199
- return 0 if total_subscribers_start == 0
200
- (churned.to_f / total_subscribers_start * 100).round(2)
201
- end
202
-
203
- def churned_subscriptions(period = DEFAULT_PERIOD)
204
- Pay::Subscription
205
- .includes(:customer)
206
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
207
- .joins(:customer)
208
- .where(status: ['canceled', 'ended'])
209
- .where(ends_at: period.ago..Time.current)
210
- end
211
-
212
- def calculate_churned_customers(period = DEFAULT_PERIOD)
213
- churned_subscriptions(period).distinct.count('customer_id')
214
- end
215
-
216
- def calculate_churned_mrr(period = DEFAULT_PERIOD)
217
- start_date = period.ago
218
- end_date = Time.current
219
-
220
- # Churned MRR = full monthly rate of subscriptions that ended in the period
221
- # MRR is a rate, not revenue, so we don't prorate
222
- Pay::Subscription
223
- .includes(:customer)
224
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
225
- .joins(:customer)
226
- .where(status: ['canceled', 'ended'])
227
- .where(ends_at: start_date..end_date)
228
- .sum do |subscription|
229
- MrrCalculator.process_subscription(subscription)
230
- end
231
- end
232
-
233
- def calculate_new_mrr(period = DEFAULT_PERIOD)
234
- start_date = period.ago
235
- end_date = Time.current
236
-
237
- # New MRR = full monthly rate of subscriptions created in the period
238
- # MRR is a rate, not revenue, so we don't prorate
239
- Pay::Subscription
240
- .active
241
- .includes(:customer)
242
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
243
- .joins(:customer)
244
- .where(created_at: start_date..end_date)
245
- .where.not(status: ['trialing', 'paused'])
246
- .sum do |subscription|
247
- MrrCalculator.process_subscription(subscription)
248
- end
249
- end
250
-
251
- def calculate_revenue_in_period(period)
252
- paid_charges.where(created_at: period.ago..Time.current).sum(:amount)
253
- end
254
-
255
- def calculate_recurring_revenue_in_period(period)
256
- paid_charges
257
- .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id')
258
- .where(created_at: period.ago..Time.current)
259
- .sum(:amount)
260
- end
261
-
262
- def calculate_recurring_revenue_percentage(period)
263
- total_revenue = calculate_revenue_in_period(period)
264
- recurring_revenue = calculate_recurring_revenue_in_period(period)
265
-
266
- return 0 if total_revenue.zero?
267
-
268
- ((recurring_revenue.to_f / total_revenue) * 100).round(2)
269
- end
270
-
271
- def calculate_total_customers
272
- Pay::Customer.joins(:charges)
273
- .merge(paid_charges)
274
- .distinct
275
- .count
276
- end
277
-
278
- def calculate_total_subscribers
279
- Pay::Customer.joins(:subscriptions).distinct.count
280
- end
281
-
282
- def calculate_active_subscribers
283
- Pay::Customer.joins(:subscriptions)
284
- .where(pay_subscriptions: { status: 'active' })
285
- .distinct
286
- .count
287
- end
288
-
289
- def actual_customers
290
- Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id")
291
- .joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id")
292
- .where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0")
293
- .distinct
294
- end
295
-
296
- def calculate_new_customers(period)
297
- actual_customers.where(created_at: period.ago..Time.current).count
298
- end
299
-
300
- def calculate_new_subscribers(period)
301
- # Count customers who got a NEW subscription in the period
302
- # (not customers created in the period, but subscriptions created in the period)
303
- Pay::Customer.joins(:subscriptions)
304
- .where(pay_subscriptions: { created_at: period.ago..Time.current })
305
- .distinct
306
- .count
307
- end
308
-
309
- def calculate_average_revenue_per_customer
310
- paying_customers = calculate_total_customers
311
- return 0 if paying_customers.zero?
312
- (all_time_revenue.to_f / paying_customers).round
313
- end
314
-
315
- def calculate_lifetime_value
316
- # LTV = Monthly ARPU / Monthly Churn Rate
317
- # where ARPU (Average Revenue Per User) = MRR / active subscribers
318
- subscribers = calculate_active_subscribers
319
- return 0 if subscribers.zero?
320
-
321
- monthly_arpu = mrr.to_f / subscribers # in cents
322
- churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05)
323
- return 0 if churn_rate.zero?
324
-
325
- (monthly_arpu / churn_rate).round # LTV in cents
326
- end
327
-
328
- def calculate_mrr_growth(period = DEFAULT_PERIOD)
329
- new_mrr = calculate_new_mrr(period)
330
- churned_mrr = calculate_churned_mrr(period)
331
- new_mrr - churned_mrr
332
- end
333
-
334
- def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
335
- end_date = Time.current
336
- start_date = end_date - period
337
-
338
- start_mrr = calculate_mrr_at(start_date)
339
- end_mrr = calculate_mrr_at(end_date)
340
-
341
- return 0 if start_mrr == 0
342
- ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2)
343
- end
344
-
345
- def calculate_mrr_at(date)
346
- # Find subscriptions that were active AT the given date:
347
- # - Created before or on that date
348
- # - Not ended before that date (ends_at is nil OR ends_at > date)
349
- # - Not paused at that date
350
- # - Not in trialing status (trials don't count as MRR)
351
- Pay::Subscription
352
- .where('pay_subscriptions.created_at <= ?', date)
353
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
354
- .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
355
- .where.not(status: ['trialing', 'paused'])
356
- .includes(:customer)
357
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
358
- .joins(:customer)
359
- .sum do |subscription|
360
- MrrCalculator.process_subscription(subscription)
361
- end
362
- end
363
-
364
- end
365
- 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.3.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-01-01 00:00:00.000000000 Z
10
+ date: 2026-03-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: pay
@@ -46,8 +46,11 @@ executables: []
46
46
  extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
49
+ - ".simplecov"
50
+ - AGENTS.md
49
51
  - Appraisals
50
52
  - CHANGELOG.md
53
+ - CLAUDE.md
51
54
  - LICENSE.txt
52
55
  - README.md
53
56
  - Rakefile
@@ -56,15 +59,19 @@ files:
56
59
  - app/views/layouts/profitable/application.html.erb
57
60
  - app/views/profitable/dashboard/index.html.erb
58
61
  - config/routes.rb
62
+ - context7.json
59
63
  - gemfiles/pay_10.0.gemfile
60
64
  - gemfiles/pay_11.0.gemfile
61
65
  - gemfiles/pay_7.3.gemfile
62
66
  - gemfiles/pay_8.3.gemfile
63
67
  - gemfiles/pay_9.0.gemfile
68
+ - gemfiles/rails_7.2.gemfile
69
+ - gemfiles/rails_8.1.gemfile
64
70
  - lib/profitable.rb
65
71
  - lib/profitable/engine.rb
66
72
  - lib/profitable/error.rb
67
73
  - lib/profitable/json_helpers.rb
74
+ - lib/profitable/metrics.rb
68
75
  - lib/profitable/mrr_calculator.rb
69
76
  - lib/profitable/numeric_result.rb
70
77
  - lib/profitable/processors/base.rb