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 +4 -4
- data/.simplecov +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +12 -11
- data/lib/profitable/json_helpers.rb +7 -4
- data/lib/profitable/metrics.rb +147 -99
- data/lib/profitable/mrr_calculator.rb +12 -38
- data/lib/profitable/numeric_result.rb +3 -0
- data/lib/profitable/processors/base.rb +7 -3
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +9 -6
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 589bf3accd7eb5a430b00a724b1419ec8f1091bde38b86debdd1e996b8608371
|
|
4
|
+
data.tar.gz: 80402781b526c6bccc9b452034d3cabcd445d82adc1e85076f899ec4201bdcb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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.
|
|
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
|
-
> `
|
|
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
|
-
>
|
|
40
|
-
> - `
|
|
41
|
-
> - `
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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.")
|
data/lib/profitable/metrics.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
257
|
-
#
|
|
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
|
-
#
|
|
265
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 { |_,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 { |_,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
39
|
+
Processors::Base.subscription_data(subscription)
|
|
69
40
|
end
|
|
70
41
|
|
|
71
42
|
def self.processor_for(processor_name)
|
|
72
|
-
# MRR parsing
|
|
73
|
-
#
|
|
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
|
|
@@ -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
|
-
|
|
23
|
+
self.class.subscription_data(subscription)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
# Converts a billing amount to its monthly equivalent rate.
|
data/lib/profitable/version.rb
CHANGED
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.
|
|
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-
|
|
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.
|