Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .simplecov
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ SimpleCov.start do
add_filter "/lib/profitable/engine.rb"
add_filter "/app/"

# Exclude the main profitable.rb entry point - it loads the engine and
# defines the Profitable module. Our unit tests use a test-specific
# Profitable module (defined in test_helper.rb) to avoid Rails dependencies.
# The core logic is tested via the individual lib/profitable/*.rb files.
# Exclude the main profitable.rb entry point - it only requires the engine
# and the gem's components. The test harness loads those components directly
# (test_helper.rb) because requiring the engine needs a full Rails app, so
# all core logic in lib/profitable/*.rb runs as real production code paths.
add_filter "/lib/profitable.rb"

# Track the lib directory (core gem logic)
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# `profitable`

## [0.6.0] - 2026-06-23

Accuracy-focused release: every metric was re-audited line by line against the actual data `pay` (7.x–11.x) stores.

- **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
- **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
- **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
- **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)
- **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
- **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)
- **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
- **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
- **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`
- Add `Profitable.mrr_at(date)` as the public historical MRR snapshot API, keeping the raw `calculate_mrr_at` helper private
- Preserve existing `Profitable::Error` messages in MRR snapshot delegation instead of double-wrapping them
- **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
- 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
- `MrrCalculator.calculate` streams subscriptions in batches (`find_each`) everywhere, keeping memory flat on large datasets
- 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

## [0.5.0] - 2026-03-19
- Add `Profitable.ttm_revenue` for trailing twelve-month revenue
- Add `Profitable.ttm` as a founder-friendly alias for `ttm_revenue`
Expand Down
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

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.)

Think doing something like: `"Current MRR: $#{Profitable.mrr}"` or `"Your app is worth $#{Profitable.valuation_estimate("3x")} at a 3x valuation"`
Think doing something like: `"Current MRR: $#{Profitable.mrr}"` or `"Your app is worth $#{Profitable.estimated_valuation("3x")} at a 3x valuation"`

## Installation

Expand All @@ -28,23 +28,18 @@ Then run `bundle install`.

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.

Current MRR processor coverage is verified for `stripe`, `braintree`, `paddle_billing`, and `paddle_classic`.

For Stripe, metered subscription items are intentionally excluded from fixed run-rate metrics like `mrr`, `arr`, `new_mrr`, and `churned_mrr`.

> [!IMPORTANT]
> `profitable` does **not** yet normalize MRR for every processor that `pay` supports.
> 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.
> 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:
>
> Verified processor-adapter coverage today:
> - `stripe`
> - `braintree`
> - `paddle_billing`
> - `paddle_classic`
> - **Subscription amount parsing (full support): `stripe`.**
> - `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).
> - Subscriptions from any other processor (e.g. `lemon_squeezy`) also contribute `0` to amount-derived metrics until an adapter is added.
>
> 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.
>
> 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.
> 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.

## Mount the `/profitable` dashboard

Expand All @@ -71,6 +66,7 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
### Revenue metrics

- `Profitable.mrr`: Monthly Recurring Revenue (MRR) from subscriptions that are billable right now
- `Profitable.mrr_at(date)`: Historical MRR snapshot from subscriptions that were billable at the given date
- `Profitable.arr`: Annual Recurring Revenue (ARR), calculated as current `mrr * 12`, not trailing revenue
- `Profitable.ttm`: Founder-friendly shorthand alias for `ttm_revenue`
- `Profitable.ttm_revenue`: Trailing twelve-month revenue, net of refunds when `amount_refunded` is present
Expand Down Expand Up @@ -118,6 +114,9 @@ All methods return numbers that can be converted to a nicely-formatted, human-re
# Get the current MRR
Profitable.mrr.to_readable # => "$1,234"

# Get the MRR snapshot for a historical date
Profitable.mrr_at(30.days.ago).to_readable # => "$987"

# Get the number of new customers in the last 60 days
Profitable.new_customers(in_the_last: 60.days).to_readable # => "42"

Expand Down Expand Up @@ -170,6 +169,8 @@ Revenue methods are net of refunds when `amount_refunded` is present on `pay_cha

### Notes on specific metrics

- **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".
- `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.
- `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.
- `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.

Expand Down
15 changes: 9 additions & 6 deletions lib/profitable.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# frozen_string_literal: true

# Third-party dependencies must load before the metrics module: NumericResult
# mixes in ActionView helpers and metrics use ActiveSupport durations at
# definition time.
require "pay"
require "active_support/time"
require "active_support/core_ext/numeric/conversions"
require "active_support/core_ext/string/filters"
require "action_view"

require_relative "profitable/version"
require_relative "profitable/error"
require_relative "profitable/engine"

require_relative "profitable/mrr_calculator"
require_relative "profitable/numeric_result"
require_relative "profitable/json_helpers"

require "pay"
require "active_support/core_ext/numeric/conversions"
require "action_view"

require_relative "profitable/metrics"
11 changes: 7 additions & 4 deletions lib/profitable/json_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ module JsonHelpers
VALID_TABLE_COLUMN_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
VALID_JSON_KEY_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/

# Returns the appropriate JSON extraction syntax for the current database adapter
# Supports PostgreSQL, MySQL (5.7.9+), and SQLite
# Returns the appropriate JSON extraction syntax for the current database adapter,
# always yielding a TEXT-typed value so comparisons behave identically everywhere.
# Supports PostgreSQL, MySQL (5.7.9+), and SQLite.
#
# @param table_column [String] The table and column name (e.g., 'pay_charges.object')
# @param json_key [String] The JSON key to extract (e.g., 'paid', 'status')
Expand All @@ -25,7 +26,7 @@ module JsonHelpers
#
# @example SQLite
# json_extract('pay_charges.object', 'paid')
# # => "json_extract(pay_charges.object, '$.paid')"
# # => "CAST(json_extract(pay_charges.object, '$.paid') AS TEXT)"
def json_extract(table_column, json_key)
# Validate inputs to prevent SQL injection
validate_table_column!(table_column)
Expand All @@ -41,7 +42,9 @@ def json_extract(table_column, json_key)
# We use JSON_UNQUOTE(JSON_EXTRACT()) for maximum compatibility
"JSON_UNQUOTE(JSON_EXTRACT(#{table_column}, '$.#{json_key}'))"
when /sqlite/
"json_extract(#{table_column}, '$.#{json_key}')"
# SQLite returns JSON booleans as integers (0/1), unlike ->> on
# PostgreSQL/MySQL which return text. CAST keeps the adapters in sync.
"CAST(json_extract(#{table_column}, '$.#{json_key}') AS TEXT)"
else
# Fallback to PostgreSQL syntax for unknown adapters
Rails.logger.warn("Unknown database adapter '#{adapter}' for JSON extraction. Falling back to PostgreSQL syntax.")
Expand Down
Loading
Loading