profitable 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db964563468a5d97aba1c885e241da6c9938c01a4e5bc491448effdd492fcb82
4
- data.tar.gz: d8cc7bc12503aaabf2572841632d3d83c629258aa8b7727233a72c6f48f2f5f1
3
+ metadata.gz: 589bf3accd7eb5a430b00a724b1419ec8f1091bde38b86debdd1e996b8608371
4
+ data.tar.gz: 80402781b526c6bccc9b452034d3cabcd445d82adc1e85076f899ec4201bdcb8
5
5
  SHA512:
6
- metadata.gz: bc7391cee28cb2e0b11c7e29583fb2651e4a35e34199f04c142322d2b7dad436f42b1523c3299b6f8a38988b376a753ef6ef7f5c5367213a262ea754b01148ef
7
- data.tar.gz: '08d67a50ca5b2b9f20202b6fc77802e2abd8249aca8a670377080c2c37759c5175b4d886f8a0ebd7031eccf61e4eab730606491fd88d483008f46a0ccefb8da0'
6
+ metadata.gz: 3cb3efa5003453fe2ba9372ac6fe51c35954c5e0e6d02829a6916c8876138d9458aee1273091a8ea28c71303be5fca826459d7343eb48141dd69d0de6309f158
7
+ data.tar.gz: cf45ac397f09ea38dda42719d629e6eb238b349dd9e02a5afb2c1291bcab166945b6ea53b1f9a8015cfe766e9be6bb9e4574e041fe7b9bb00a4f8d7ecfd6c9c4
data/.simplecov CHANGED
@@ -15,10 +15,10 @@ SimpleCov.start do
15
15
  add_filter "/lib/profitable/engine.rb"
16
16
  add_filter "/app/"
17
17
 
18
- # Exclude the main profitable.rb entry point - it loads the engine and
19
- # defines the Profitable module. Our unit tests use a test-specific
20
- # Profitable module (defined in test_helper.rb) to avoid Rails dependencies.
21
- # The core logic is tested via the individual lib/profitable/*.rb files.
18
+ # Exclude the main profitable.rb entry point - it only requires the engine
19
+ # and the gem's components. The test harness loads those components directly
20
+ # (test_helper.rb) because requiring the engine needs a full Rails app, so
21
+ # all core logic in lib/profitable/*.rb runs as real production code paths.
22
22
  add_filter "/lib/profitable.rb"
23
23
 
24
24
  # Track the lib directory (core gem logic)
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # `profitable`
2
2
 
3
+ ## [0.6.0] - 2026-06-23
4
+
5
+ Accuracy-focused release: every metric was re-audited line by line against the actual data `pay` (7.x–11.x) stores.
6
+
7
+ - **Fix canceled trials polluting nearly every flow metric**: a trial that was canceled without ever converting no longer counts in `total_subscribers`, `total_customers`, `new_customers`, `new_subscribers`, `new_mrr`, `churned_customers`, `churned_mrr`, `churn`, or the monthly/daily summaries. A subscription now only counts as ever-billable if it ended *after* billing started
8
+ - **Fix survivor bias in `monthly_summary` churn rates**: the churn denominator now includes subscribers who churned later inside the window, so early months no longer overstate churn
9
+ - **Fix summaries counting future trial conversions**: a trial scheduled to convert later this month/day no longer appears as a new subscriber (and new MRR) before it actually converts
10
+ - **Fix `paid: false` charges counting as revenue on SQLite**: JSON booleans surface as integers on SQLite (vs. text on PostgreSQL/MySQL); JSON extraction is now text-cast so all adapters filter identically (Rails 8 runs SQLite in production)
11
+ - **Handle Braintree/Lemon Squeezy statuses**: `expired` now counts as churn (at `ends_at`) instead of billable-forever, and `pending` (future start date, never billed) is excluded everywhere
12
+ - **Fix `time_to_next_mrr_milestone` returning a bare String**: it now returns a `NumericResult` like every other metric, so the documented `.to_readable` works (plain string comparisons still behave the same)
13
+ - **Fix Pay 7-9 schema compatibility for charge filtering**: revenue queries now only reference `pay_charges.object` when that column exists, so legacy `data`-only schemas do not crash
14
+ - **Fix paused subscriptions disappearing from lifecycle metrics**: paused subscriptions without a local pause start date remain excluded from current MRR, but still count as customers/subscribers if they had already become billable
15
+ - **Fix two small edge cases**: sub-dollar positive MRR no longer reports as "No MRR yet" for milestone messaging, and monthly churn denominators now match the inclusive period-start semantics used by `churn`
16
+ - Add `Profitable.mrr_at(date)` as the public historical MRR snapshot API, keeping the raw `calculate_mrr_at` helper private
17
+ - Preserve existing `Profitable::Error` messages in MRR snapshot delegation instead of double-wrapping them
18
+ - **Correct the processor coverage docs**: `pay` only stores subscription price payloads locally for Stripe, so Braintree/Paddle subscriptions contribute 0 to MRR-style metrics unless the payload is backfilled — the README previously overstated this; charge-based and lifecycle-based metrics remain fully portable across all processors
19
+ - DRY consolidation: MRR "billable right now" and "billable at date" are now literally the same query (`MrrCalculator.calculate` delegates to the shared snapshot), churn-event queries are defined once and reused by all metrics and summaries, and `subscription_data` has a single source of truth
20
+ - `MrrCalculator.calculate` streams subscriptions in batches (`find_each`) everywhere, keeping memory flat on large datasets
21
+ - Test coverage increased to ~98% line / ~90% branch, including regression tests for every bug above, exercised against Pay 7.3–11.x and Rails 7.2–8.1
22
+
3
23
  ## [0.5.0] - 2026-03-19
4
24
  - Add `Profitable.ttm_revenue` for trailing twelve-month revenue
5
25
  - Add `Profitable.ttm` as a founder-friendly alias for `ttm_revenue`
data/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  Usually, you would look into your Stripe Dashboard or query the Stripe API to know your MRR / ARR / churn – but when you're using `pay`, you already have that data available and auto synced to your own database. So we can leverage it to make handy, composable ActiveRecord queries that you can reuse in any part of your Rails app (dashboards, internal pages, reports, status messages, etc.)
17
17
 
18
- Think doing something like: `"Current MRR: $#{Profitable.mrr}"` or `"Your app is worth $#{Profitable.valuation_estimate("3x")} at a 3x valuation"`
18
+ Think doing something like: `"Current MRR: $#{Profitable.mrr}"` or `"Your app is worth $#{Profitable.estimated_valuation("3x")} at a 3x valuation"`
19
19
 
20
20
  ## Installation
21
21
 
@@ -28,23 +28,18 @@ Then run `bundle install`.
28
28
 
29
29
  Provided you have a valid [`pay`](https://github.com/pay-rails/pay) installation (`Pay::Customer`, `Pay::Subscription`, `Pay::Charge`, etc.) everything is already set up and you can just start using [`Profitable` methods](#main-profitable-methods) right away.
30
30
 
31
- Current MRR processor coverage is verified for `stripe`, `braintree`, `paddle_billing`, and `paddle_classic`.
32
-
33
31
  For Stripe, metered subscription items are intentionally excluded from fixed run-rate metrics like `mrr`, `arr`, `new_mrr`, and `churned_mrr`.
34
32
 
35
33
  > [!IMPORTANT]
36
- > `profitable` does **not** yet normalize MRR for every processor that `pay` supports.
37
- > If a subscription comes from an unsupported processor such as `lemon_squeezy`, it will currently contribute `0` to processor-adapter-dependent metrics until an adapter is added.
34
+ > MRR-style metrics need each subscription's price payload, and `pay` only stores that payload locally for **Stripe** (in the `object` column on Pay v10+, and in `data['subscription_items']` on Pay 7–9). So:
38
35
  >
39
- > Verified processor-adapter coverage today:
40
- > - `stripe`
41
- > - `braintree`
42
- > - `paddle_billing`
43
- > - `paddle_classic`
36
+ > - **Subscription amount parsing (full support): `stripe`.**
37
+ > - `braintree`, `paddle_billing`, and `paddle_classic` subscriptions contribute `0` to amount-derived metrics out of the box, because `pay` does not persist their price payloads locally. `profitable` ships parsing adapters for them, so they are supported if your app backfills `pay_subscriptions.object` with the processor's subscription payload — otherwise they are counted in subscriber/churn metrics but add `0` MRR (never wrong amounts).
38
+ > - Subscriptions from any other processor (e.g. `lemon_squeezy`) also contribute `0` to amount-derived metrics until an adapter is added.
44
39
  >
45
40
  > Metrics that depend on processor-specific subscription amount parsing include `mrr`, `arr`, `new_mrr`, `churned_mrr`, `mrr_growth`, `mrr_growth_rate`, `lifetime_value`, `time_to_next_mrr_milestone`, and MRR-derived fields in summaries.
46
41
  >
47
- > Metrics based primarily on `Pay::Charge` and generic subscription lifecycle fields are much more portable across processors, including `all_time_revenue`, `revenue_in_period`, `ttm_revenue`, `revenue_run_rate`, customer counts, subscriber counts, and churn calculations.
42
+ > Metrics based on `Pay::Charge` and generic subscription lifecycle fields are portable across **all** processors, including `all_time_revenue`, `revenue_in_period`, `ttm_revenue`, `revenue_run_rate`, customer counts, subscriber counts, and churn calculations — `pay` stores real amounts and lifecycle dates locally for every processor.
48
43
 
49
44
  ## Mount the `/profitable` dashboard
50
45
 
@@ -71,6 +66,7 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
71
66
  ### Revenue metrics
72
67
 
73
68
  - `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now
69
+ - `Profitable.mrr_at(date)`: Historical MRR snapshot from subscriptions that were billable at the given date
74
70
  - `Profitable.arr`: Annual Recurring Revenue (ARR), calculated as current `mrr * 12`, not trailing revenue
75
71
  - `Profitable.ttm`: Founder-friendly shorthand alias for `ttm_revenue`
76
72
  - `Profitable.ttm_revenue`: Trailing twelve-month revenue, net of refunds when `amount_refunded` is present
@@ -118,6 +114,9 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
118
114
  # Get the current MRR
119
115
  Profitable.mrr.to_readable # => "$1,234"
120
116
 
117
+ # Get the MRR snapshot for a historical date
118
+ Profitable.mrr_at(30.days.ago).to_readable # => "$987"
119
+
121
120
  # Get the number of new customers in the last 60 days
122
121
  Profitable.new_customers(in_the_last: 60.days).to_readable # => "42"
123
122
 
@@ -170,6 +169,8 @@ Revenue methods are net of refunds when `amount_refunded` is present on `pay_cha
170
169
 
171
170
  ### Notes on specific metrics
172
171
 
172
+ - **Canceled trials never count.** A subscription only enters subscriber counts, new/churned MRR, churn rates, and the dashboard summaries if it actually started billing before it ended. A free trial that gets canceled (before or at conversion) adds nothing to any metric — it was never revenue, so it is neither a "new subscriber" nor "churn".
173
+ - `churn` and `churned_customers`: a churn event happens when billing actually stops (`ends_at`), not when the cancellation is requested. A subscription canceled with a grace period keeps counting in MRR and `active_subscribers` until the grace period runs out.
173
174
  - `mrr_growth_rate`: This calculation compares the MRR at the start and end of the specified period. It assumes a linear growth rate over the period, which may not reflect short-term fluctuations. For more accurate results, consider using shorter periods or implementing a more sophisticated growth calculation method if needed.
174
175
  - `time_to_next_mrr_milestone`: This estimation is based on the current MRR and the recent growth rate. It assumes a constant growth rate, which may not reflect real-world conditions. The calculation may be inaccurate for very new businesses or those with irregular growth patterns.
175
176
 
@@ -7,8 +7,9 @@ module Profitable
7
7
  VALID_TABLE_COLUMN_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
8
8
  VALID_JSON_KEY_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
9
9
 
10
- # Returns the appropriate JSON extraction syntax for the current database adapter
11
- # Supports PostgreSQL, MySQL (5.7.9+), and SQLite
10
+ # Returns the appropriate JSON extraction syntax for the current database adapter,
11
+ # always yielding a TEXT-typed value so comparisons behave identically everywhere.
12
+ # Supports PostgreSQL, MySQL (5.7.9+), and SQLite.
12
13
  #
13
14
  # @param table_column [String] The table and column name (e.g., 'pay_charges.object')
14
15
  # @param json_key [String] The JSON key to extract (e.g., 'paid', 'status')
@@ -25,7 +26,7 @@ module Profitable
25
26
  #
26
27
  # @example SQLite
27
28
  # json_extract('pay_charges.object', 'paid')
28
- # # => "json_extract(pay_charges.object, '$.paid')"
29
+ # # => "CAST(json_extract(pay_charges.object, '$.paid') AS TEXT)"
29
30
  def json_extract(table_column, json_key)
30
31
  # Validate inputs to prevent SQL injection
31
32
  validate_table_column!(table_column)
@@ -41,7 +42,9 @@ module Profitable
41
42
  # We use JSON_UNQUOTE(JSON_EXTRACT()) for maximum compatibility
42
43
  "JSON_UNQUOTE(JSON_EXTRACT(#{table_column}, '$.#{json_key}'))"
43
44
  when /sqlite/
44
- "json_extract(#{table_column}, '$.#{json_key}')"
45
+ # SQLite returns JSON booleans as integers (0/1), unlike ->> on
46
+ # PostgreSQL/MySQL which return text. CAST keeps the adapters in sync.
47
+ "CAST(json_extract(#{table_column}, '$.#{json_key}') AS TEXT)"
45
48
  else
46
49
  # Fallback to PostgreSQL syntax for unknown adapters
47
50
  Rails.logger.warn("Unknown database adapter '#{adapter}' for JSON extraction. Falling back to PostgreSQL syntax.")
@@ -4,9 +4,16 @@ module Profitable
4
4
  # Pay exposes some processor-specific status variants beyond the core generic list.
5
5
  # We normalize them into business-meaningful groups so current-state metrics,
6
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).
7
14
  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
15
+ CHURNED_STATUSES = ['canceled', 'cancelled', 'ended', 'deleted', 'expired'].freeze
16
+ NEVER_BILLABLE_SUBSCRIPTION_STATUSES = ['incomplete', 'incomplete_expired', 'unpaid', 'pending'].freeze
10
17
 
11
18
  class << self
12
19
  include ActionView::Helpers::NumberHelper
@@ -22,6 +29,11 @@ module Profitable
22
29
  NumericResult.new(MrrCalculator.calculate)
23
30
  end
24
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
+
25
37
  # Annual Recurring Revenue (ARR) based on the current recurring base.
26
38
  # This is today's MRR annualized, not historical 12-month revenue.
27
39
  def arr
@@ -63,7 +75,7 @@ module Profitable
63
75
  NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage)
64
76
  end
65
77
 
66
- def revenue_run_rate(in_the_last: 30.days)
78
+ def revenue_run_rate(in_the_last: DEFAULT_PERIOD)
67
79
  NumericResult.new(calculate_revenue_run_rate(in_the_last))
68
80
  end
69
81
 
@@ -83,7 +95,7 @@ module Profitable
83
95
  NumericResult.new(calculate_estimated_valuation_from(ttm_revenue.to_i, actual_multiplier))
84
96
  end
85
97
 
86
- def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: 30.days)
98
+ def estimated_revenue_run_rate_valuation(multiplier = nil, at: nil, multiple: nil, in_the_last: DEFAULT_PERIOD)
87
99
  actual_multiplier = multiplier || at || multiple || 3
88
100
  NumericResult.new(calculate_estimated_valuation_from(revenue_run_rate(in_the_last:).to_i, actual_multiplier))
89
101
  end
@@ -148,25 +160,7 @@ module Profitable
148
160
  end
149
161
 
150
162
  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')})"
163
+ NumericResult.new(calculate_time_to_next_mrr_milestone, :string)
170
164
  end
171
165
 
172
166
  def monthly_summary(months: 12)
@@ -198,10 +192,23 @@ module Profitable
198
192
  'COALESCE(pay_subscriptions.trial_ends_at, pay_subscriptions.created_at)'
199
193
  end
200
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
+
201
208
  # We intentionally do not reuse Pay::Subscription.active here.
202
209
  # Pay's active scope is access-oriented and can include free-trial access,
203
210
  # while profitable needs billable subscription semantics for metrics.
204
- def subscription_is_billable_by(date, scope = Pay::Subscription.all)
211
+ def subscription_has_billable_lifecycle_by(date, scope = Pay::Subscription.all)
205
212
  scope
206
213
  .where.not(status: NEVER_BILLABLE_SUBSCRIPTION_STATUSES)
207
214
  .where(
@@ -213,6 +220,11 @@ module Profitable
213
220
  "(pay_subscriptions.status NOT IN (?) OR pay_subscriptions.ends_at IS NOT NULL)",
214
221
  CHURNED_STATUSES
215
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)
216
228
  .where(
217
229
  "(pay_subscriptions.status != ? OR pay_subscriptions.pause_starts_at IS NOT NULL)",
218
230
  'paused'
@@ -222,7 +234,7 @@ module Profitable
222
234
  # Any subscription that has ever crossed into a paid/billable state,
223
235
  # even if it later churned. This is used for "ever" style counts.
224
236
  def ever_billable_subscription_scope(scope = Pay::Subscription.all)
225
- subscription_is_billable_by(Time.current, scope)
237
+ subscription_has_billable_lifecycle_by(Time.current, scope)
226
238
  .where("#{subscription_became_billable_at_sql} <= ?", Time.current)
227
239
  end
228
240
 
@@ -244,44 +256,76 @@ module Profitable
244
256
  # Historical "new subscriber" / "new MRR" event window.
245
257
  # The event date is when billing starts, not when the subscription record is created.
246
258
  def billable_subscription_events_in_period(period_start, period_end, scope = Pay::Subscription.all)
247
- subscription_is_billable_by(period_end, scope)
259
+ subscription_has_billable_lifecycle_by(period_end, scope)
248
260
  .where("#{subscription_became_billable_at_sql} BETWEEN ? AND ?", period_start, period_end)
249
261
  end
250
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
+
251
273
  def subscription_became_billable_at(subscription)
252
274
  subscription.trial_ends_at || subscription.created_at
253
275
  end
254
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
+
255
285
  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
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.
258
290
  #
259
291
  # Performance note: The COALESCE pattern may prevent index usage on some databases.
260
292
  # This is an acceptable tradeoff for backwards compatibility with Pay < 10.
261
293
  # For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+
262
294
  # where only the `object` column is used.
295
+ paid = charge_json_value_sql('paid')
296
+ status = charge_json_value_sql('status')
263
297
 
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
-
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.
270
300
  Pay::Charge
271
301
  .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
- )
302
+ .where(<<~SQL.squish, 'false', '0', 'succeeded')
303
+ (#{paid} IS NULL OR #{paid} NOT IN (?, ?))
277
304
  AND
278
- (
279
- COALESCE(#{status_object}, #{status_data}) = ?
280
- OR COALESCE(#{status_object}, #{status_data}) IS NULL
281
- )
305
+ (#{status} = ? OR #{status} IS NULL)
282
306
  SQL
283
307
  end
284
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
+
285
329
  # Revenue metrics should reflect net cash collected, not gross billed amounts.
286
330
  # When Pay stores refunded cents on the charge, subtract them from revenue.
287
331
  def net_revenue(scope)
@@ -297,11 +341,7 @@ module Profitable
297
341
  end
298
342
 
299
343
  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)
344
+ mrr.to_i * 12
305
345
  end
306
346
 
307
347
  def calculate_estimated_valuation_from(base_amount, multiplier = 3)
@@ -356,7 +396,7 @@ module Profitable
356
396
  def calculate_recurring_revenue_in_period(period)
357
397
  net_revenue(
358
398
  paid_charges
359
- .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id')
399
+ .joins(:subscription)
360
400
  .where(created_at: period.ago..Time.current)
361
401
  )
362
402
  end
@@ -441,6 +481,15 @@ module Profitable
441
481
  new_mrr - churned_mrr
442
482
  end
443
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
+
444
493
  def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
445
494
  end_date = Time.current
446
495
  start_date = end_date - period
@@ -452,17 +501,26 @@ module Profitable
452
501
  ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2)
453
502
  end
454
503
 
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
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')})"
466
524
  end
467
525
 
468
526
  def calculate_period_data(period)
@@ -480,7 +538,7 @@ module Profitable
480
538
  # Churn rate (reuses churned_count)
481
539
  total_at_start = billable_subscription_scope_at(period_start)
482
540
  .distinct
483
- .count('customer_id')
541
+ .count(:customer_id)
484
542
  churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
485
543
 
486
544
  {
@@ -497,18 +555,17 @@ module Profitable
497
555
  # Batched: loads all data in 5 queries then groups by month in Ruby
498
556
  def calculate_monthly_summary(months_count)
499
557
  overall_start = (months_count - 1).months.ago.beginning_of_month
500
- overall_end = Time.current.end_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
501
561
 
502
562
  # Bulk load all data for the full range, then group in Ruby.
503
563
  # This keeps the dashboard query count low while preserving the same
504
564
  # 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))
565
+ new_sub_records = billable_subscription_events_in_period(overall_start, overall_end)
507
566
  .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql))
508
567
 
509
- churned_sub_records = Pay::Subscription
510
- .where(status: CHURNED_STATUSES)
511
- .where(ends_at: overall_start..overall_end)
568
+ churned_sub_records = churned_subscription_events_in_period(overall_start, overall_end)
512
569
  .pluck(:customer_id, :ends_at)
513
570
 
514
571
  new_mrr_subs = subscriptions_with_processor(
@@ -516,14 +573,16 @@ module Profitable
516
573
  ).to_a
517
574
 
518
575
  churned_mrr_subs = subscriptions_with_processor(
519
- Pay::Subscription
520
- .where(status: CHURNED_STATUSES)
521
- .where(ends_at: overall_start..overall_end)
576
+ churned_subscription_events_in_period(overall_start, overall_end)
522
577
  ).to_a
523
578
 
524
- churn_base_records = billable_subscription_scope_at(overall_end, Pay::Subscription)
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)
525
584
  .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)
585
+ .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql), :ends_at, :pause_starts_at)
527
586
 
528
587
  # Group by month in Ruby using billable-at and ends_at as the event dates,
529
588
  # rather than raw subscription created_at.
@@ -533,7 +592,7 @@ module Profitable
533
592
  month_end = month_start.end_of_month
534
593
 
535
594
  new_count = new_sub_records
536
- .select { |_, created_at| created_at >= month_start && created_at <= month_end }
595
+ .select { |_, became_billable_at| became_billable_at >= month_start && became_billable_at <= month_end }
537
596
  .map(&:first).uniq.count
538
597
 
539
598
  churned_count = churned_sub_records
@@ -549,7 +608,11 @@ module Profitable
549
608
  .sum { |s| MrrCalculator.process_subscription(s) }
550
609
 
551
610
  total_at_start = churn_base_records
552
- .select { |_, billable_at, ends_at| billable_at < month_start && (ends_at.nil? || ends_at > month_start) }
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
553
616
  .map(&:first).uniq.count
554
617
 
555
618
  churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
@@ -573,17 +636,15 @@ module Profitable
573
636
  # Batched: loads all data in 2 queries then groups by day in Ruby
574
637
  def calculate_daily_summary(days_count)
575
638
  overall_start = (days_count - 1).days.ago.beginning_of_day
576
- overall_end = Time.current.end_of_day
639
+ # Capped at now so trials scheduled to convert later today do not count yet.
640
+ overall_end = Time.current
577
641
 
578
642
  # Daily summary intentionally uses the same "became billable" event date as
579
643
  # 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))
644
+ new_sub_records = billable_subscription_events_in_period(overall_start, overall_end)
582
645
  .pluck(:customer_id, Arel.sql(subscription_became_billable_at_sql))
583
646
 
584
- churned_sub_records = Pay::Subscription
585
- .where(status: CHURNED_STATUSES)
586
- .where(ends_at: overall_start..overall_end)
647
+ churned_sub_records = churned_subscription_events_in_period(overall_start, overall_end)
587
648
  .pluck(:customer_id, :ends_at)
588
649
 
589
650
  summary = []
@@ -592,7 +653,7 @@ module Profitable
592
653
  day_end = day_start.end_of_day
593
654
 
594
655
  new_count = new_sub_records
595
- .select { |_, created_at| created_at >= day_start && created_at <= day_end }
656
+ .select { |_, became_billable_at| became_billable_at >= day_start && became_billable_at <= day_end }
596
657
  .map(&:first).uniq.count
597
658
 
598
659
  churned_count = churned_sub_records
@@ -617,34 +678,21 @@ module Profitable
617
678
  end
618
679
 
619
680
  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)
681
+ churned_subscription_events_in_period(period_start, period_end)
624
682
  .distinct
625
- .count('customer_id')
683
+ .count(:customer_id)
626
684
  end
627
685
 
628
686
  def calculate_new_mrr_in_period(period_start, period_end)
629
687
  # New MRR is the full fixed monthly value of subscriptions whose billing
630
688
  # started in the window. It is not prorated, and it still counts if the
631
689
  # 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
690
+ mrr_sum(billable_subscription_events_in_period(period_start, period_end))
637
691
  end
638
692
 
639
693
  def calculate_churned_mrr_in_period(period_start, period_end)
640
694
  # 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
695
+ mrr_sum(churned_subscription_events_in_period(period_start, period_end))
648
696
  end
649
697
 
650
698
  def calculate_churn_rate_for_period(period_start, period_end)
@@ -652,7 +700,7 @@ module Profitable
652
700
  # This keeps free trials and not-yet-paying subscriptions out of the denominator.
653
701
  total_subscribers_start = billable_subscription_scope_at(period_start)
654
702
  .distinct
655
- .count('customer_id')
703
+ .count(:customer_id)
656
704
 
657
705
  churned = calculate_churned_subscribers_in_period(period_start, period_end)
658
706
  return 0 if total_subscribers_start == 0
@@ -6,40 +6,13 @@ require_relative 'processors/paddle_classic_processor'
6
6
 
7
7
  module Profitable
8
8
  class MrrCalculator
9
+ # Current MRR is just the historical MRR snapshot taken right now.
10
+ # The billable-subscription query lives in Profitable's metrics module so
11
+ # current MRR, MRR-at-date, and growth rates can never drift apart.
9
12
  def self.calculate
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.
15
- subscriptions = Pay::Subscription
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)
33
- .includes(:customer)
34
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
35
- .joins(:customer)
36
-
37
- subscriptions.find_each do |subscription|
38
- mrr = process_subscription(subscription)
39
- total_mrr += mrr if mrr.is_a?(Numeric) && mrr > 0
40
- end
41
-
42
- total_mrr
13
+ Profitable.mrr_at(Time.current).to_i
14
+ rescue Profitable::Error
15
+ raise
43
16
  rescue => e
44
17
  Rails.logger.error("Error calculating total MRR: #{e.message}")
45
18
  raise Profitable::Error, "Failed to calculate MRR: #{e.message}"
@@ -62,15 +35,16 @@ module Profitable
62
35
  0
63
36
  end
64
37
 
65
- # Pay gem v10+ stores Stripe objects in the `object` column,
66
- # while older versions used `data`. This method provides backwards compatibility.
67
38
  def self.subscription_data(subscription)
68
- subscription.try(:object) || subscription.try(:data)
39
+ Processors::Base.subscription_data(subscription)
69
40
  end
70
41
 
71
42
  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.
43
+ # MRR parsing needs the processor's price payload stored locally, which Pay
44
+ # only does for Stripe (in `object` on Pay v10+, `data` before that).
45
+ # The remaining adapters only apply when that payload has been backfilled by
46
+ # the application; otherwise unknown or payload-less subscriptions safely
47
+ # contribute zero instead of guessing.
74
48
  case processor_name
75
49
  when 'stripe'
76
50
  Processors::StripeProcessor
@@ -1,3 +1,6 @@
1
+ require "delegate"
2
+ require "action_view"
3
+
1
4
  module Profitable
2
5
  class NumericResult < SimpleDelegator
3
6
  include ActionView::Helpers::NumberHelper
@@ -3,6 +3,12 @@ module Profitable
3
3
  class Base
4
4
  attr_reader :subscription
5
5
 
6
+ # Pay gem v10+ stores processor payloads in the `object` column,
7
+ # while older versions used `data`. Single source of truth for that fallback.
8
+ def self.subscription_data(subscription)
9
+ subscription.try(:object) || subscription.try(:data)
10
+ end
11
+
6
12
  def initialize(subscription)
7
13
  @subscription = subscription
8
14
  end
@@ -13,10 +19,8 @@ module Profitable
13
19
 
14
20
  protected
15
21
 
16
- # Pay gem v10+ stores Stripe objects in the `object` column,
17
- # while older versions used `data`. This method provides backwards compatibility.
18
22
  def subscription_data
19
- subscription.try(:object) || subscription.try(:data)
23
+ self.class.subscription_data(subscription)
20
24
  end
21
25
 
22
26
  # Converts a billing amount to its monthly equivalent rate.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profitable
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/profitable.rb CHANGED
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Third-party dependencies must load before the metrics module: NumericResult
4
+ # mixes in ActionView helpers and metrics use ActiveSupport durations at
5
+ # definition time.
6
+ require "pay"
7
+ require "active_support/time"
8
+ require "active_support/core_ext/numeric/conversions"
9
+ require "active_support/core_ext/string/filters"
10
+ require "action_view"
11
+
3
12
  require_relative "profitable/version"
4
13
  require_relative "profitable/error"
5
14
  require_relative "profitable/engine"
6
-
7
15
  require_relative "profitable/mrr_calculator"
8
16
  require_relative "profitable/numeric_result"
9
17
  require_relative "profitable/json_helpers"
10
-
11
- require "pay"
12
- require "active_support/core_ext/numeric/conversions"
13
- require "action_view"
14
-
15
18
  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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-19 00:00:00.000000000 Z
10
+ date: 2026-06-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: pay
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '5.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: actionview
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '5.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '5.2'
40
54
  description: Calculate SaaS metrics like the MRR, ARR, churn, LTV, ARPU, total revenue,
41
55
  estimated valuation, and other business metrics of your `pay`-powered Rails app
42
56
  – and display them in a simple dashboard.