profitable 0.4.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: f24a9849fbf5f3d6b87beaa3f0a4100d18a45ccb2b585e8e48b417d303759593
4
- data.tar.gz: 7c8523a9547f30468ee7517a92af604aa66c7bcc269e6292200d8722f83c30ff
3
+ metadata.gz: 589bf3accd7eb5a430b00a724b1419ec8f1091bde38b86debdd1e996b8608371
4
+ data.tar.gz: 80402781b526c6bccc9b452034d3cabcd445d82adc1e85076f899ec4201bdcb8
5
5
  SHA512:
6
- metadata.gz: 4b79c94e7288c859f7419a8afe27c4352db1be8cff2c269b9d9d57348761a59e5f3a48cfe4b601496a1d4642b88fd5e2e5f349a1733f133488bb47a153b6a453
7
- data.tar.gz: 216578eb9882a946fed3818c7d9301449727cd2b1bc64b282e90e1d8cf056b2f05ff801e49748ec46cf89a008771d796ec65daf9ce99d7ed058da5fa0d284e01
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,39 @@
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
+
23
+ ## [0.5.0] - 2026-03-19
24
+ - Add `Profitable.ttm_revenue` for trailing twelve-month revenue
25
+ - Add `Profitable.ttm` as a founder-friendly alias for `ttm_revenue`
26
+ - Add `Profitable.revenue_run_rate`, `estimated_arr_valuation`, `estimated_ttm_revenue_valuation`, and `estimated_revenue_run_rate_valuation`
27
+ - Make revenue metrics net of refunds when `amount_refunded` is present
28
+ - Make subscriber and MRR metrics distinguish between current billable subscriptions and historical period events
29
+ - Count `new_customers` from first monetization date rather than signup date
30
+ - Count `new_subscribers` / `new_mrr` from when a subscription becomes billable, not when a free trial starts
31
+ - Handle additional Pay status variants like `on_trial`, `cancelled`, and `deleted`
32
+ - Keep grace-period subscriptions billable until `ends_at`
33
+ - Exclude metered Stripe items from fixed run-rate MRR calculations
34
+ - Surface TTM revenue in the built-in dashboard
35
+ - Extract shared metric logic to `lib/profitable/metrics.rb` for cleaner architecture
36
+
3
37
  ## [0.4.0] - 2026-02-10
4
38
  - Add monthly summary (12mo) and daily summary (30d) tables to dashboard
5
39
  - Add `period_data` method for efficient batch computation of period metrics
@@ -32,4 +66,4 @@
32
66
 
33
67
  ## [0.1.0] - 2024-08-29
34
68
 
35
- - Initial test release (not production ready)
69
+ - Initial test release (not production ready)
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,6 +28,19 @@ 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
+ For Stripe, metered subscription items are intentionally excluded from fixed run-rate metrics like `mrr`, `arr`, `new_mrr`, and `churned_mrr`.
32
+
33
+ > [!IMPORTANT]
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:
35
+ >
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.
39
+ >
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.
41
+ >
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.
43
+
31
44
  ## Mount the `/profitable` dashboard
32
45
 
33
46
  `profitable` also provides a simple dashboard to see your main business metrics.
@@ -52,25 +65,32 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
52
65
 
53
66
  ### Revenue metrics
54
67
 
55
- - `Profitable.mrr`: Monthly Recurring Revenue (MRR)
56
- - `Profitable.arr`: Annual Recurring Revenue (ARR)
57
- - `Profitable.all_time_revenue`: Total revenue since launch
58
- - `Profitable.revenue_in_period(in_the_last: 30.days)`: Total revenue (recurring and non-recurring) in the specified period
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
70
+ - `Profitable.arr`: Annual Recurring Revenue (ARR), calculated as current `mrr * 12`, not trailing revenue
71
+ - `Profitable.ttm`: Founder-friendly shorthand alias for `ttm_revenue`
72
+ - `Profitable.ttm_revenue`: Trailing twelve-month revenue, net of refunds when `amount_refunded` is present
73
+ - `Profitable.revenue_run_rate(in_the_last: 30.days)`: Recent revenue annualized (useful for TrustMRR-style revenue multiples)
74
+ - `Profitable.all_time_revenue`: Net revenue since launch
75
+ - `Profitable.revenue_in_period(in_the_last: 30.days)`: Net revenue (recurring and non-recurring) in the specified period
59
76
  - `Profitable.recurring_revenue_in_period(in_the_last: 30.days)`: Only recurring revenue in the specified period
60
77
  - `Profitable.recurring_revenue_percentage(in_the_last: 30.days)`: Percentage of revenue that is recurring in the specified period
61
- - `Profitable.new_mrr(in_the_last: 30.days)`: New MRR added in the specified period
78
+ - `Profitable.new_mrr(in_the_last: 30.days)`: Full MRR from subscriptions that first became billable in the specified period
62
79
  - `Profitable.churned_mrr(in_the_last: 30.days)`: MRR lost due to churn in the specified period
63
80
  - `Profitable.average_revenue_per_customer`: Average revenue per customer (ARPC)
64
81
  - `Profitable.lifetime_value`: Estimated customer lifetime value (LTV)
65
- - `Profitable.estimated_valuation(at: "3x")`: Estimated company valuation based on ARR
82
+ - `Profitable.estimated_valuation(at: "3x")`: ARR-based valuation heuristic
83
+ - `Profitable.estimated_arr_valuation(at: "3x")`: Explicit ARR-based valuation heuristic
84
+ - `Profitable.estimated_ttm_revenue_valuation(at: "2x")`: TTM revenue-based valuation heuristic
85
+ - `Profitable.estimated_revenue_run_rate_valuation(at: "2x", in_the_last: 30.days)`: Recent revenue run-rate valuation heuristic
66
86
 
67
87
  ### Customer metrics
68
88
 
69
- - `Profitable.total_customers`: Total number of customers who have ever made a purchase or had a subscription (current and past)
70
- - `Profitable.total_subscribers`: Total number of customers who have ever had a subscription (active or not)
71
- - `Profitable.active_subscribers`: Number of customers with currently active subscriptions
72
- - `Profitable.new_customers(in_the_last: 30.days)`: Number of new customers added in the specified period
73
- - `Profitable.new_subscribers(in_the_last: 30.days)`: Number of new subscribers added in the specified period
89
+ - `Profitable.total_customers`: Total number of customers who have ever monetized through a paid charge or a paid subscription state
90
+ - `Profitable.total_subscribers`: Total number of customers who have ever reached a paid subscription state (trial-only subscriptions do not count)
91
+ - `Profitable.active_subscribers`: Number of customers with subscriptions that are billable right now
92
+ - `Profitable.new_customers(in_the_last: 30.days)`: Number of first-time customers added in the period based on first monetization date, not signup date
93
+ - `Profitable.new_subscribers(in_the_last: 30.days)`: Number of customers whose subscriptions first became billable in the specified period
74
94
  - `Profitable.churned_customers(in_the_last: 30.days)`: Number of customers who churned in the specified period
75
95
 
76
96
  ### Other metrics
@@ -94,6 +114,9 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
94
114
  # Get the current MRR
95
115
  Profitable.mrr.to_readable # => "$1,234"
96
116
 
117
+ # Get the MRR snapshot for a historical date
118
+ Profitable.mrr_at(30.days.ago).to_readable # => "$987"
119
+
97
120
  # Get the number of new customers in the last 60 days
98
121
  Profitable.new_customers(in_the_last: 60.days).to_readable # => "42"
99
122
 
@@ -104,11 +127,24 @@ Profitable.churn(in_the_last: 3.months).to_readable # => "12%"
104
127
  Profitable.new_mrr(in_the_last: 24.hours).to_readable(2) # => "$123.45"
105
128
 
106
129
  # Get the estimated valuation at 5x ARR (defaults to 3x if no multiple is specified)
107
- Profitable.estimated_valuation(multiple: 5).to_readable # => "$500,000"
130
+ Profitable.estimated_arr_valuation(multiple: 5).to_readable # => "$500,000"
131
+
132
+ # Get trailing twelve-month revenue
133
+ Profitable.ttm_revenue.to_readable # => "$123,456"
134
+
135
+ # Founder-friendly shorthand for trailing twelve-month revenue
136
+ Profitable.ttm.to_readable # => "$123,456"
108
137
 
109
- # You can also pass the multiplier as a string. You can also use the `at:` keyword argument (same thing as `multiplier:`) – and/or ignore the `at:` or `multiplier:` named arguments altogether
138
+ # Get recent revenue annualized (useful for TrustMRR-style revenue multiples)
139
+ Profitable.revenue_run_rate(in_the_last: 30.days).to_readable # => "$96,000"
140
+
141
+ # `estimated_valuation` remains as a backwards-compatible alias of `estimated_arr_valuation`
110
142
  Profitable.estimated_valuation(at: "4.5x").to_readable # => "$450,000"
111
143
 
144
+ # Be explicit about the denominator when comparing marketplace comps
145
+ Profitable.estimated_ttm_revenue_valuation(2).to_readable
146
+ Profitable.estimated_revenue_run_rate_valuation(2.7, in_the_last: 30.days).to_readable
147
+
112
148
  # Get the time to next MRR milestone
113
149
  Profitable.time_to_next_mrr_milestone.to_readable # => "26 days left to $10,000 MRR"
114
150
  ```
@@ -129,11 +165,207 @@ For more precise calculations, you can access the raw numeric value:
129
165
  Profitable.mrr # => 123456
130
166
  ```
131
167
 
168
+ Revenue methods are net of refunds when `amount_refunded` is present on `pay_charges`.
169
+
132
170
  ### Notes on specific metrics
133
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.
134
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.
135
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.
136
176
 
177
+ ## Metric guide: TTM, Revenue, Profit, ARR, and MRR
178
+
179
+ `profitable` exposes both standard recurring revenue metrics (`MRR`, `ARR`) and trailing actuals (`TTM revenue`) on purpose.
180
+
181
+ These metrics are related, but they are not interchangeable:
182
+
183
+ | Metric | What it means | Best for | What it is **not** |
184
+ | --- | --- | --- | --- |
185
+ | `MRR` | Monthly Recurring Revenue from subscriptions that are billable right now | Operating cadence, near-term momentum, tracking upgrades/downgrades | It's **not** monthly cash collected from all sources |
186
+ | `ARR` | Annual Recurring Revenue, calculated as the current recurring base annualized | Forecasting recurring scale, board/investor reporting, recurring-revenue quality | It's **not** a historical last-12-month revenue |
187
+ | `MRR * 12` | Simple annualization of the current monthly recurring base | Fast ARR approximation when the base is normalized monthly | It's **not** TTM revenue or TTM profit |
188
+ | `TTM revenue` | Actual revenue collected over the last 12 months | Buyer-facing historical actuals, smoothing seasonality, sanity-checking ARR | It's **not** a forward recurring run-rate |
189
+ | `TTM profit` | Actual profit over the last 12 months | Small bootstrapped SaaS exits, ROI-minded buyers, earnings-based multiples | It's **not** something `profitable` can derive from `pay` alone |
190
+
191
+ ### The distinction
192
+
193
+ - `ARR` is a run-rate metric. Stripe describes it as revenue you "expect to earn in a year" and notes that `ARR = MRR × 12`.
194
+ - `TTM` is a trailing metric. CFI defines it as the "most recent 12-month period" and uses it for reported actuals such as revenue and EBITDA.
195
+ - `TTM revenue` tells you what customers actually paid over the last year.
196
+ - `TTM profit` tells you what the business actually kept after costs. This is often what smaller acquisition buyers care about most, but it requires cost data outside `pay`.
197
+ - In acquire-style market reports, `TTM` can refer to **both** `TTM profit` and `TTM revenue` depending on the chart. The denominator must always be stated explicitly.
198
+ - In `profitable`, the shorthand method `ttm` is defined to mean `ttm_revenue` because the gem does not yet model costs or profit.
199
+
200
+ In other words:
201
+
202
+ - `ARR` answers: "What is my current recurring run-rate? What do I expect to earn in a year?"
203
+ - `TTM revenue` answers: "What did I actually collect over the last year?"
204
+
205
+ ### What `profitable` calculates
206
+
207
+ - `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now
208
+ - `Profitable.arr`: Annual Recurring Revenue (ARR), calculated from current MRR
209
+ - `Profitable.ttm`: shorthand alias for `ttm_revenue`
210
+ - `Profitable.ttm_revenue`: trailing 12-month revenue, net of refunds when `amount_refunded` is present
211
+ - `Profitable.revenue_run_rate`: recent revenue annualized to a yearly run-rate
212
+ - `Profitable.estimated_valuation`: ARR-multiple heuristic
213
+ - `Profitable.estimated_ttm_revenue_valuation`: TTM revenue heuristic
214
+ - `Profitable.estimated_revenue_run_rate_valuation`: recent revenue run-rate heuristic
215
+
216
+ `profitable` does **not** calculate `TTM profit`, because payroll, contractor spend, hosting, support, software tools, taxes, and owner add-backs do not live inside `pay`.
217
+
218
+ ### Which metric matters in which situation?
219
+
220
+ - If you're operating the business week to week: `MRR` is usually the best pulse metric.
221
+ - If you want to understand your current subscription run-rate: `ARR` is the right metric.
222
+ - If you're preparing buyer materials for a bootstrapped SaaS exit: add `TTM revenue` and your own `TTM profit`.
223
+ - If your business has meaningful one-time revenue, services, setup fees, or seasonal swings: `TTM revenue` matters more than `ARR`.
224
+ - If you are speaking to serious SaaS buyers about revenue quality: pair `ARR` with churn, growth, concentration, and margins.
225
+
226
+ ### What the market says
227
+
228
+ These are short excerpts from current market and finance sources, followed by why they matter for `profitable`.
229
+
230
+ - [Acquire.com Biannual Multiples Report (Jan 2026)](https://blog.acquire.com/acquire-com-biannual-acquisition-multiples-report-jan-2026/): "anchor valuation on profit"
231
+ Acquire says the January 2026 report is focused "entirely on profit multiples," which is highly relevant for smaller bootstrapped SaaS exits.
232
+ In the same report, some visual breakdowns segment businesses by `TTM revenue` bands, so it is important not to assume one bare `TTM` label means the same thing everywhere.
233
+
234
+ - [Acquire.com Biannual Multiples Report, January 2024](https://blog.acquire.com/wp-content/uploads/2024/01/Acquire-Biannual-Multiples-Report-Jan-2024.pdf): "4.3x TTM profit"
235
+ The earlier report gives a concrete historical benchmark for how these profit multiples looked on the marketplace.
236
+
237
+ - [Acquire.com 2025 webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/): "$100k-$1M in TTM revenue"
238
+ Acquire says that cohort averaged `4.35x`, which is a useful live-market anchor for micro-SaaS exits.
239
+
240
+ - [Acquire.com SaaS valuation multiples guide](https://blog.acquire.com/saas-valuation-multiples/): "5x to 15x ARR"
241
+ Stronger recurring SaaS businesses are also routinely discussed in ARR-multiple terms, especially when growth and retention are strong.
242
+
243
+ - [Stripe on ARR](https://stripe.com/us/resources/more/acv-vs-arr-what-each-metric-really-means-and-when-they-matter): "ARR = £50,000 x 12 = £600,000"
244
+ This is the clearest shorthand for why `ARR` is a run-rate, not a trailing actual.
245
+
246
+ - [CFI on TTM](https://corporatefinanceinstitute.com/resources/valuation/railing-twelve-months-ttm-definition/): "most recent 12-month period"
247
+ This is why `ttm_revenue` belongs beside `arr`: it measures trailing actuals, not a projection.
248
+
249
+ - [Quiet Light on selling SaaS](https://quietlight.com/sell-your-saas-business-for-the-best-price/): "EBITDA or SDE"
250
+ Quiet Light explicitly says smaller SaaS businesses are often valued on earnings, not revenue, which is why `TTM profit` matters.
251
+
252
+ - [Quiet Light on larger SaaS](https://quietlight.com/sell-your-saas-business-for-the-best-price/): "ARR of $1M or more"
253
+ The same source says larger SaaS businesses may qualify for revenue multiples, which is why `ARR` becomes more important as the business scales.
254
+
255
+ - [Software Equity Group, 3Q25 SaaS M&A](https://softwareequity.com/blog/saas-ma-deal-volume-and-valuations): "5.4x"
256
+ SEG reported average SaaS M&A valuations of `5.4x` revenue in 3Q25, which is useful context for larger, more institutional software transactions.
257
+
258
+ - [TrustMRR live listing example](https://trustmrr.com/startup/appalchemy): "$164,819 TTM revenue"
259
+ Live marketplaces increasingly show `TTM revenue`, `TTM profit`, and `ARR` side by side, which matches how buyers actually compare deals.
260
+
261
+ - [TrustMRR FAQ](https://trustmrr.com/faq): "asking price divided by annualized revenue"
262
+ TrustMRR explicitly defines its marketplace multiple as asking price divided by `last 30 days revenue × 12`, so its multiple is a revenue run-rate multiple, not an ARR multiple.
263
+
264
+ - [TrustMRR FAQ](https://trustmrr.com/faq): "Only aggregate revenue metrics"
265
+ TrustMRR says it only pulls revenue-level aggregates from payment providers, which is another reason its native multiple is revenue-based rather than profit-based.
266
+
267
+ - [TrustMRR FAQ](https://trustmrr.com/faq): "profit margin for the last 30 days"
268
+ TrustMRR asks sellers to provide profit margin separately when listing for sale, which reinforces that profit-based heuristics need cost inputs outside the payment provider.
269
+
270
+ ### How to use these metrics responsibly
271
+
272
+ - `estimated_valuation` is intentionally simple. It is kept as a backwards-compatible ARR heuristic. Prefer `estimated_arr_valuation` in new code when you want the denominator to be explicit.
273
+ - Do not compare an ARR multiple and a TTM profit multiple as if they were the same kind of number. They are based on different denominators.
274
+ - A `4x TTM profit` deal, a `2x TTM revenue` deal, and an `8x ARR` deal can all describe reasonable SaaS outcomes in different buyer segments.
275
+ - If two businesses both have `$300k ARR`, the one with lower churn, better margins, lower concentration, and cleaner growth usually deserves the higher multiple.
276
+ - If two businesses both have `$300k TTM revenue`, the one with stronger profit and more recurring revenue usually deserves the higher price.
277
+
278
+ ### Typical multiples by SaaS type and size
279
+
280
+ These are rough, source-backed heuristics. They are not interchangeable.
281
+
282
+ | SaaS profile | Common denominator | Rough multiple | Source |
283
+ | --- | --- | --- | --- |
284
+ | Smaller profitable SaaS on Acquire.com (2024-2025 confirmed transactions) | `TTM profit` | `3.9x` median | [Acquire.com Jan 2026 report](https://blog.acquire.com/acquire-com-biannual-acquisition-multiples-report-jan-2026/) |
285
+ | Micro-SaaS under `$100k` TTM revenue | `TTM profit` | `3.55x` average | [Acquire.com webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/) |
286
+ | Micro-SaaS with `$100k-$1M` TTM revenue | `TTM profit` | `4.35x` average | [Acquire.com webinar recap](https://blog.acquire.com/the-secrets-behind-2024s-biggest-exits-webinar-recap/) |
287
+ | TrustMRR marketplace listings | `Annualized last 30d revenue` | often roughly `0.6x-5.5x` ask multiples | [TrustMRR homepage snapshot](https://trustmrr.com/) |
288
+ | Mid-6-figure ARR SaaS | `TTM revenue` | `2x-4x` revenue | [Acquire.com founder-driven acquisition recap](https://blog.acquire.com/how-founders-can-drive-their-own-acquisition-process-webinar-recap/) |
289
+ | Older Acquire.com SaaS baseline | `TTM revenue` or `TTM profit` | `2-3x revenue` or `5x profit` | [Acquire.com 7-8 figures webinar recap](https://blog.acquire.com/how-to-sell-your-company-playbook-webinar/) |
290
+ | Strong recurring SaaS with high growth and retention | `ARR` | `5x-15x ARR` | [Acquire.com SaaS valuation multiples guide](https://blog.acquire.com/saas-valuation-multiples/) |
291
+
292
+ How to read this table:
293
+
294
+ - Smaller bootstrapped SaaS buyers on Acquire-style marketplaces often underwrite on `TTM profit`.
295
+ - If profit is low or intentionally reinvested, buyers may fall back to `TTM revenue`.
296
+ - TrustMRR listing multiples are a secondary comparison set: they are based on recent revenue run-rate, specifically `last 30 days revenue × 12`.
297
+ - Higher-quality SaaS with real scale, low churn, and strong growth is more likely to be discussed in `ARR` terms.
298
+
299
+ ### Rough valuation formulas from `profitable`
300
+
301
+ You can only multiply a metric by a multiple if the denominator matches.
302
+
303
+ #### 1. ARR multiple
304
+
305
+ This is already built into the gem:
306
+
307
+ ```ruby
308
+ # Example: 6x ARR
309
+ Profitable.estimated_arr_valuation(multiple: 6).to_readable
310
+ ```
311
+
312
+ Use this when:
313
+
314
+ - your business is strongly recurring,
315
+ - churn and retention are solid,
316
+ - and you want a run-rate-based heuristic.
317
+
318
+ #### 2. TTM revenue multiple
319
+
320
+ This is useful when buyers care more about trailing actuals than annualized run-rate:
321
+
322
+ ```ruby
323
+ ttm_revenue_cents = Profitable.ttm_revenue.to_i
324
+
325
+ low_estimate_cents = (ttm_revenue_cents * 2.0).round
326
+ high_estimate_cents = (ttm_revenue_cents * 4.0).round
327
+ ```
328
+
329
+ Use this when:
330
+
331
+ - the business has meaningful one-time revenue,
332
+ - profit is thin because you are reinvesting,
333
+ - or the buyer is thinking in revenue-band terms.
334
+
335
+ #### 2b. Recent revenue run-rate multiple
336
+
337
+ This is the closest match to TrustMRR-style marketplace multiples:
338
+
339
+ ```ruby
340
+ # Default: annualized last-30-days revenue
341
+ Profitable.revenue_run_rate.to_readable
342
+ Profitable.estimated_revenue_run_rate_valuation(2.7).to_readable
343
+ ```
344
+
345
+ Use this when:
346
+
347
+ - you're comparing against TrustMRR listings,
348
+ - the market is quoting a multiple on recent revenue rather than ARR,
349
+ - and you want the denominator to match the marketplace comp.
350
+
351
+ #### 3. TTM profit multiple
352
+
353
+ `profitable` cannot calculate this yet because it does not know your costs.
354
+
355
+ ```ruby
356
+ # You need to compute this outside of profitable:
357
+ ttm_profit_cents = your_ttm_profit_cents
358
+
359
+ low_estimate_cents = (ttm_profit_cents * 3.5).round
360
+ high_estimate_cents = (ttm_profit_cents * 4.35).round
361
+ ```
362
+
363
+ Use this when:
364
+
365
+ - the business is a smaller profitable micro-SaaS,
366
+ - the buyer is focused on ROI and cash flow,
367
+ - or you're comparing yourself to Acquire.com-style marketplace comps.
368
+
137
369
  ## Development
138
370
 
139
371
  After checking out the repo, install dependencies:
@@ -4,6 +4,7 @@ module Profitable
4
4
  @mrr = Profitable.mrr
5
5
  @mrr_growth_rate = Profitable.mrr_growth_rate
6
6
  @total_customers = Profitable.total_customers
7
+ @ttm_revenue = Profitable.ttm_revenue
7
8
  @all_time_revenue = Profitable.all_time_revenue
8
9
  @estimated_valuation = Profitable.estimated_valuation
9
10
  @average_revenue_per_customer = Profitable.average_revenue_per_customer
@@ -53,6 +53,10 @@
53
53
  <h2><%= @mrr.to_readable %></h2>
54
54
  <p>MRR</p>
55
55
  </div>
56
+ <div class="card">
57
+ <h2><%= @ttm_revenue.to_readable %></h2>
58
+ <p>TTM revenue</p>
59
+ </div>
56
60
  <div class="card">
57
61
  <h2><%= @estimated_valuation.to_readable %></h2>
58
62
  <p>Valuation at 3x ARR</p>
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/profitable",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
@@ -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.")