diff --git a/app/controllers/profitable/dashboard_controller.rb b/app/controllers/profitable/dashboard_controller.rb
index 8ac1c2f..38d6efd 100644
--- a/app/controllers/profitable/dashboard_controller.rb
+++ b/app/controllers/profitable/dashboard_controller.rb
@@ -1,12 +1,24 @@
module Profitable
class DashboardController < BaseController
def index
- end
+ @mrr = Profitable.mrr
+ @mrr_growth_rate = Profitable.mrr_growth_rate
+ @total_customers = Profitable.total_customers
+ @all_time_revenue = Profitable.all_time_revenue
+ @estimated_valuation = Profitable.estimated_valuation
+ @average_revenue_per_customer = Profitable.average_revenue_per_customer
+ @lifetime_value = Profitable.lifetime_value
- private
+ @show_milestone = @mrr_growth_rate > 0
+ @milestone_message = Profitable.time_to_next_mrr_milestone if @show_milestone
- def test
- end
+ @monthly_summary = Profitable.monthly_summary(months: 12)
+ @daily_summary = Profitable.daily_summary(days: 30)
+ @periods = [24.hours, 7.days, 30.days]
+ @period_data = @periods.each_with_object({}) do |period, hash|
+ hash[period] = Profitable.period_data(in_the_last: period)
+ end
+ end
end
end
diff --git a/app/views/profitable/dashboard/index.html.erb b/app/views/profitable/dashboard/index.html.erb
index 3ba78e9..65feff1 100644
--- a/app/views/profitable/dashboard/index.html.erb
+++ b/app/views/profitable/dashboard/index.html.erb
@@ -1,4 +1,9 @@
+
+
-
<%= Profitable.new_customers(in_the_last: period).to_readable %>
+
<%= data[:new_customers].to_readable %>
new customers (<%= period_short %>)
-
<%= Profitable.churned_customers(in_the_last: period).to_readable %>
+
<%= data[:churned_customers].to_readable %>
churned customers (<%= period_short %>)
-
<%= Profitable.churn(in_the_last: period).to_readable %>
+
<%= data[:churn].to_readable %>
churn (<%= period_short %>)
-
<%= Profitable.new_mrr(in_the_last: period).to_readable %>
+
<%= data[:new_mrr].to_readable %>
new MRR (<%= period_short %>)
-
<%= Profitable.churned_mrr(in_the_last: period).to_readable %>
+
<%= data[:churned_mrr].to_readable %>
churned MRR (<%= period_short %>)
-
<%= Profitable.mrr_growth(in_the_last: period).to_readable %>
+
<%= data[:mrr_growth].to_readable %>
MRR growth (<%= period_short %>)
-
<%= Profitable.revenue_in_period(in_the_last: period).to_readable %>
+
<%= data[:revenue].to_readable %>
total revenue (<%= period_short %>)
diff --git a/lib/profitable.rb b/lib/profitable.rb
index db5c3ab..ed924e6 100644
--- a/lib/profitable.rb
+++ b/lib/profitable.rb
@@ -13,6 +13,10 @@
require "action_view"
module Profitable
+ # Subscription status constants (at module level so MrrCalculator can reference them)
+ EXCLUDED_STATUSES = ['trialing', 'paused'].freeze
+ CHURNED_STATUSES = ['canceled', 'ended'].freeze
+
class << self
include ActionView::Helpers::NumberHelper
include Profitable::JsonHelpers
@@ -123,8 +127,28 @@ def time_to_next_mrr_milestone
"#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
end
+ def monthly_summary(months: 12)
+ calculate_monthly_summary(months)
+ end
+
+ def daily_summary(days: 30)
+ calculate_daily_summary(days)
+ end
+
+ def period_data(in_the_last: DEFAULT_PERIOD)
+ calculate_period_data(in_the_last)
+ end
+
private
+ # Helper to load subscriptions with processor info from customer
+ def subscriptions_with_processor(scope = Pay::Subscription.all)
+ scope
+ .includes(:customer)
+ .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
+ .joins(:customer)
+ end
+
def paid_charges
# Pay gem v10+ stores charge data in `object` column, older versions used `data`
# We check both columns for backwards compatibility using database-agnostic JSON extraction
@@ -184,68 +208,19 @@ def parse_multiplier(input)
end
def calculate_churn(period = DEFAULT_PERIOD)
- start_date = period.ago
-
- # Count subscribers who were active AT the start of the period
- # (not just currently active, but active at that historical point)
- total_subscribers_start = Pay::Subscription
- .where('pay_subscriptions.created_at < ?', start_date)
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', start_date)
- .where.not(status: ['trialing', 'paused'])
- .distinct
- .count('customer_id')
-
- churned = calculate_churned_customers(period)
- return 0 if total_subscribers_start == 0
- (churned.to_f / total_subscribers_start * 100).round(2)
- end
-
- def churned_subscriptions(period = DEFAULT_PERIOD)
- Pay::Subscription
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .where(status: ['canceled', 'ended'])
- .where(ends_at: period.ago..Time.current)
+ calculate_churn_rate_for_period(period.ago, Time.current)
end
def calculate_churned_customers(period = DEFAULT_PERIOD)
- churned_subscriptions(period).distinct.count('customer_id')
+ calculate_churned_subscribers_in_period(period.ago, Time.current)
end
def calculate_churned_mrr(period = DEFAULT_PERIOD)
- start_date = period.ago
- end_date = Time.current
-
- # Churned MRR = full monthly rate of subscriptions that ended in the period
- # MRR is a rate, not revenue, so we don't prorate
- Pay::Subscription
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .where(status: ['canceled', 'ended'])
- .where(ends_at: start_date..end_date)
- .sum do |subscription|
- MrrCalculator.process_subscription(subscription)
- end
+ calculate_churned_mrr_in_period(period.ago, Time.current)
end
def calculate_new_mrr(period = DEFAULT_PERIOD)
- start_date = period.ago
- end_date = Time.current
-
- # New MRR = full monthly rate of subscriptions created in the period
- # MRR is a rate, not revenue, so we don't prorate
- Pay::Subscription
- .active
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .where(created_at: start_date..end_date)
- .where.not(status: ['trialing', 'paused'])
- .sum do |subscription|
- MrrCalculator.process_subscription(subscription)
- end
+ calculate_new_mrr_in_period(period.ago, Time.current)
end
def calculate_revenue_in_period(period)
@@ -298,12 +273,7 @@ def calculate_new_customers(period)
end
def calculate_new_subscribers(period)
- # Count customers who got a NEW subscription in the period
- # (not customers created in the period, but subscriptions created in the period)
- Pay::Customer.joins(:subscriptions)
- .where(pay_subscriptions: { created_at: period.ago..Time.current })
- .distinct
- .count
+ calculate_new_subscribers_in_period(period.ago, Time.current)
end
def calculate_average_revenue_per_customer
@@ -348,17 +318,213 @@ def calculate_mrr_at(date)
# - Not ended before that date (ends_at is nil OR ends_at > date)
# - Not paused at that date
# - Not in trialing status (trials don't count as MRR)
+ subscriptions_with_processor(
+ Pay::Subscription
+ .where('pay_subscriptions.created_at <= ?', date)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
+ .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
+ .where.not(status: EXCLUDED_STATUSES)
+ ).sum do |subscription|
+ MrrCalculator.process_subscription(subscription)
+ end
+ end
+
+ def calculate_period_data(period)
+ period_start = period.ago
+ period_end = Time.current
+
+ new_customers_count = actual_customers.where(created_at: period_start..period_end).count
+ churned_count = calculate_churned_subscribers_in_period(period_start, period_end)
+ new_mrr_val = calculate_new_mrr_in_period(period_start, period_end)
+ churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end)
+ revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount)
+
+ # Churn rate (reuses churned_count)
+ total_at_start = Pay::Subscription
+ .where('pay_subscriptions.created_at < ?', period_start)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
+ .where.not(status: EXCLUDED_STATUSES)
+ .distinct
+ .count('customer_id')
+ churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
+
+ {
+ new_customers: NumericResult.new(new_customers_count, :integer),
+ churned_customers: NumericResult.new(churned_count, :integer),
+ churn: NumericResult.new(churn_rate, :percentage),
+ new_mrr: NumericResult.new(new_mrr_val),
+ churned_mrr: NumericResult.new(churned_mrr_val),
+ mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val),
+ revenue: NumericResult.new(revenue_val)
+ }
+ end
+
+ # Batched: loads all data in 5 queries then groups by month in Ruby
+ def calculate_monthly_summary(months_count)
+ overall_start = (months_count - 1).months.ago.beginning_of_month
+ overall_end = Time.current.end_of_month
+
+ # Bulk load all data for the full range
+ new_sub_records = Pay::Subscription
+ .where(created_at: overall_start..overall_end)
+ .where.not(status: EXCLUDED_STATUSES)
+ .pluck(:customer_id, :created_at)
+
+ churned_sub_records = Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: overall_start..overall_end)
+ .pluck(:customer_id, :ends_at)
+
+ new_mrr_subs = subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: 'active')
+ .where(created_at: overall_start..overall_end)
+ ).to_a
+
+ churned_mrr_subs = subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: overall_start..overall_end)
+ ).to_a
+
+ churn_base_records = Pay::Subscription
+ .where('pay_subscriptions.created_at < ?', overall_end)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start)
+ .where.not(status: EXCLUDED_STATUSES)
+ .pluck(:customer_id, :created_at, :ends_at)
+
+ # Group by month in Ruby
+ summary = []
+ (months_count - 1).downto(0) do |months_ago|
+ month_start = months_ago.months.ago.beginning_of_month
+ month_end = month_start.end_of_month
+
+ new_count = new_sub_records
+ .select { |_, created_at| created_at >= month_start && created_at <= month_end }
+ .map(&:first).uniq.count
+
+ churned_count = churned_sub_records
+ .select { |_, ends_at| ends_at >= month_start && ends_at <= month_end }
+ .map(&:first).uniq.count
+
+ new_mrr_amount = new_mrr_subs
+ .select { |s| s.created_at >= month_start && s.created_at <= month_end }
+ .sum { |s| MrrCalculator.process_subscription(s) }
+
+ churned_mrr_amount = churned_mrr_subs
+ .select { |s| s.ends_at >= month_start && s.ends_at <= month_end }
+ .sum { |s| MrrCalculator.process_subscription(s) }
+
+ total_at_start = churn_base_records
+ .select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) }
+ .map(&:first).uniq.count
+
+ churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
+
+ summary << {
+ month: month_start.strftime('%Y-%m'),
+ month_date: month_start,
+ new_subscribers: new_count,
+ churned_subscribers: churned_count,
+ net_subscribers: new_count - churned_count,
+ new_mrr: new_mrr_amount,
+ churned_mrr: churned_mrr_amount,
+ net_mrr: new_mrr_amount - churned_mrr_amount,
+ churn_rate: churn_rate
+ }
+ end
+
+ summary
+ end
+
+ # Batched: loads all data in 2 queries then groups by day in Ruby
+ def calculate_daily_summary(days_count)
+ overall_start = (days_count - 1).days.ago.beginning_of_day
+ overall_end = Time.current.end_of_day
+
+ new_sub_records = Pay::Subscription
+ .where(created_at: overall_start..overall_end)
+ .where.not(status: EXCLUDED_STATUSES)
+ .pluck(:customer_id, :created_at)
+
+ churned_sub_records = Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: overall_start..overall_end)
+ .pluck(:customer_id, :ends_at)
+
+ summary = []
+ (days_count - 1).downto(0) do |days_ago|
+ day_start = days_ago.days.ago.beginning_of_day
+ day_end = day_start.end_of_day
+
+ new_count = new_sub_records
+ .select { |_, created_at| created_at >= day_start && created_at <= day_end }
+ .map(&:first).uniq.count
+
+ churned_count = churned_sub_records
+ .select { |_, ends_at| ends_at >= day_start && ends_at <= day_end }
+ .map(&:first).uniq.count
+
+ summary << {
+ date: day_start.to_date,
+ new_subscribers: new_count,
+ churned_subscribers: churned_count
+ }
+ end
+
+ summary
+ end
+
+ # Consolidated methods that work with any date range
+ def calculate_new_subscribers_in_period(period_start, period_end)
+ Pay::Customer.joins(:subscriptions)
+ .where(pay_subscriptions: { created_at: period_start..period_end })
+ .where.not(pay_subscriptions: { status: EXCLUDED_STATUSES })
+ .distinct
+ .count
+ end
+
+ def calculate_churned_subscribers_in_period(period_start, period_end)
Pay::Subscription
- .where('pay_subscriptions.created_at <= ?', date)
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
- .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
- .where.not(status: ['trialing', 'paused'])
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .sum do |subscription|
- MrrCalculator.process_subscription(subscription)
- end
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: period_start..period_end)
+ .distinct
+ .count('customer_id')
+ end
+
+ def calculate_new_mrr_in_period(period_start, period_end)
+ subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: 'active')
+ .where(created_at: period_start..period_end)
+ ).sum do |subscription|
+ MrrCalculator.process_subscription(subscription)
+ end
+ end
+
+ def calculate_churned_mrr_in_period(period_start, period_end)
+ subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: period_start..period_end)
+ ).sum do |subscription|
+ MrrCalculator.process_subscription(subscription)
+ end
+ end
+
+ def calculate_churn_rate_for_period(period_start, period_end)
+ # Count subscribers who were active AT the start of the period
+ total_subscribers_start = Pay::Subscription
+ .where('pay_subscriptions.created_at < ?', period_start)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
+ .where.not(status: EXCLUDED_STATUSES)
+ .distinct
+ .count('customer_id')
+
+ churned = calculate_churned_subscribers_in_period(period_start, period_end)
+ return 0 if total_subscribers_start == 0
+
+ (churned.to_f / total_subscribers_start * 100).round(1)
end
end
diff --git a/lib/profitable/mrr_calculator.rb b/lib/profitable/mrr_calculator.rb
index 5eb4ad5..a35888c 100644
--- a/lib/profitable/mrr_calculator.rb
+++ b/lib/profitable/mrr_calculator.rb
@@ -10,7 +10,7 @@ def self.calculate
total_mrr = 0
subscriptions = Pay::Subscription
.active
- .where.not(status: ['trialing', 'paused'])
+ .where.not(status: Profitable::EXCLUDED_STATUSES)
.includes(:customer)
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
.joins(:customer)
diff --git a/test/profitable_test.rb b/test/profitable_test.rb
index aadaa85..5a79262 100644
--- a/test/profitable_test.rb
+++ b/test/profitable_test.rb
@@ -642,6 +642,251 @@ def test_time_to_next_mrr_milestone_returns_message_when_no_growth
assert_includes message, "Unable to calculate"
end
+ # ============================================================================
+ # MONTHLY SUMMARY
+ # ============================================================================
+
+ def test_monthly_summary_returns_array_of_hashes
+ result = Profitable.monthly_summary(months: 3)
+
+ assert_kind_of Array, result
+ assert_equal 3, result.length
+ result.each do |month_data|
+ assert_kind_of Hash, month_data
+ assert month_data.key?(:month)
+ assert month_data.key?(:month_date)
+ assert month_data.key?(:new_subscribers)
+ assert month_data.key?(:churned_subscribers)
+ assert month_data.key?(:net_subscribers)
+ assert month_data.key?(:new_mrr)
+ assert month_data.key?(:churned_mrr)
+ assert month_data.key?(:net_mrr)
+ assert month_data.key?(:churn_rate)
+ end
+ end
+
+ def test_monthly_summary_captures_new_subscribers
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 9900,
+ interval: "month"
+ )
+
+ result = Profitable.monthly_summary(months: 1)
+ current_month = result.first
+
+ assert_equal 1, current_month[:new_subscribers]
+ assert_equal 9900, current_month[:new_mrr]
+ end
+
+ def test_monthly_summary_captures_churned_subscribers
+ churned_sub = create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 5000,
+ interval: "month",
+ status: "canceled"
+ )
+ churned_sub.update!(
+ created_at: 60.days.ago,
+ ends_at: 5.days.ago
+ )
+
+ result = Profitable.monthly_summary(months: 1)
+ current_month = result.first
+
+ assert_equal 1, current_month[:churned_subscribers]
+ assert_equal 5000, current_month[:churned_mrr]
+ end
+
+ def test_monthly_summary_calculates_net_correctly
+ # New subscriber this month
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 9900,
+ interval: "month"
+ )
+
+ # Churned subscriber this month
+ churned_customer = create_customer(processor: "stripe")
+ churned_sub = create_stripe_subscription_v10(
+ customer: churned_customer,
+ unit_amount: 5000,
+ interval: "month",
+ status: "canceled"
+ )
+ churned_sub.update!(
+ created_at: 60.days.ago,
+ ends_at: 5.days.ago
+ )
+
+ result = Profitable.monthly_summary(months: 1)
+ current_month = result.first
+
+ assert_equal 0, current_month[:net_subscribers] # 1 new - 1 churned
+ assert_equal 4900, current_month[:net_mrr] # 9900 - 5000
+ end
+
+ def test_monthly_summary_ordered_oldest_first
+ result = Profitable.monthly_summary(months: 3)
+
+ # Should be ordered oldest to newest
+ dates = result.map { |m| m[:month_date] }
+ assert_equal dates, dates.sort
+ end
+
+ # ============================================================================
+ # DAILY SUMMARY
+ # ============================================================================
+
+ def test_daily_summary_returns_array_of_hashes
+ result = Profitable.daily_summary(days: 7)
+
+ assert_kind_of Array, result
+ assert_equal 7, result.length
+ result.each do |day_data|
+ assert_kind_of Hash, day_data
+ assert day_data.key?(:date)
+ assert day_data.key?(:new_subscribers)
+ assert day_data.key?(:churned_subscribers)
+ end
+ end
+
+ def test_daily_summary_captures_new_subscriber_today
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 9900,
+ interval: "month"
+ )
+
+ result = Profitable.daily_summary(days: 1)
+ today = result.first
+
+ assert_equal Date.current, today[:date]
+ assert_equal 1, today[:new_subscribers]
+ end
+
+ def test_daily_summary_captures_churned_subscriber
+ churned_sub = create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 5000,
+ interval: "month",
+ status: "canceled"
+ )
+ churned_sub.update!(
+ created_at: 60.days.ago,
+ ends_at: Time.current
+ )
+
+ result = Profitable.daily_summary(days: 1)
+ today = result.first
+
+ assert_equal 1, today[:churned_subscribers]
+ end
+
+ def test_daily_summary_ordered_oldest_first
+ result = Profitable.daily_summary(days: 7)
+
+ dates = result.map { |d| d[:date] }
+ assert_equal dates, dates.sort
+ end
+
+ # ============================================================================
+ # NEW SUBSCRIBERS EXCLUDES TRIALING
+ # ============================================================================
+
+ def test_new_subscribers_excludes_trialing_subscriptions
+ # Trialing subscription should NOT count as a new subscriber
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 9900,
+ interval: "month",
+ status: "trialing"
+ )
+
+ assert_equal 0, Profitable.new_subscribers(in_the_last: 30.days).to_i
+ end
+
+ def test_new_subscribers_includes_active_subscriptions
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 9900,
+ interval: "month",
+ status: "active"
+ )
+
+ assert_equal 1, Profitable.new_subscribers(in_the_last: 30.days).to_i
+ end
+
+ # ============================================================================
+ # PERIOD DATA
+ # ============================================================================
+
+ def test_period_data_returns_hash_with_all_keys
+ result = Profitable.period_data(in_the_last: 30.days)
+
+ assert_kind_of Hash, result
+ [:new_customers, :churned_customers, :churn, :new_mrr, :churned_mrr, :mrr_growth, :revenue].each do |key|
+ assert result.key?(key), "Missing key: #{key}"
+ assert_kind_of Profitable::NumericResult, result[key]
+ end
+ end
+
+ def test_period_data_matches_individual_methods
+ # Active subscription
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 9900,
+ interval: "month"
+ )
+
+ # Churned subscription
+ churned_customer = create_customer(processor: "stripe")
+ churned_sub = create_stripe_subscription_v10(
+ customer: churned_customer,
+ unit_amount: 5000,
+ interval: "month",
+ status: "canceled"
+ )
+ churned_sub.update!(created_at: 45.days.ago, ends_at: 10.days.ago)
+
+ # Charge for revenue
+ create_successful_charge(customer: @customer, amount: 9900)
+
+ period = 30.days
+ data = Profitable.period_data(in_the_last: period)
+
+ assert_equal Profitable.new_customers(in_the_last: period).to_i, data[:new_customers].to_i
+ assert_equal Profitable.churned_customers(in_the_last: period).to_i, data[:churned_customers].to_i
+ assert_equal Profitable.churn(in_the_last: period).to_f, data[:churn].to_f
+ assert_equal Profitable.new_mrr(in_the_last: period).to_i, data[:new_mrr].to_i
+ assert_equal Profitable.churned_mrr(in_the_last: period).to_i, data[:churned_mrr].to_i
+ assert_equal Profitable.mrr_growth(in_the_last: period).to_i, data[:mrr_growth].to_i
+ assert_equal Profitable.revenue_in_period(in_the_last: period).to_i, data[:revenue].to_i
+ end
+
+ def test_period_data_new_mrr_and_churned_mrr
+ create_stripe_subscription_v10(
+ customer: @customer,
+ unit_amount: 10000,
+ interval: "month"
+ )
+
+ churned_customer = create_customer(processor: "stripe")
+ churned_sub = create_stripe_subscription_v10(
+ customer: churned_customer,
+ unit_amount: 5000,
+ interval: "month",
+ status: "canceled"
+ )
+ churned_sub.update!(created_at: 45.days.ago, ends_at: 15.days.ago)
+
+ data = Profitable.period_data(in_the_last: 30.days)
+
+ assert_equal 10000, data[:new_mrr].to_i
+ assert_equal 5000, data[:churned_mrr].to_i
+ assert_equal 5000, data[:mrr_growth].to_i
+ end
+
# ============================================================================
# REGRESSION: paid_charges backwards compatibility
# ============================================================================
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 5e81d45..9cba1c1 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -127,8 +127,16 @@ class Charge < ActiveRecord::Base
require "active_support/core_ext/numeric/conversions"
-# Define the Profitable module (mirroring the real implementation)
+# Define the Profitable module (mirroring the real implementation in lib/profitable.rb)
+# IMPORTANT: This must be kept in sync with lib/profitable.rb.
+# We can't load lib/profitable.rb directly because `require "pay"` loads the full
+# Pay engine which needs Rails. Instead we define minimal Pay models above and
+# mirror the Profitable module here.
module Profitable
+ # Subscription status constants (at module level so MrrCalculator can reference them)
+ EXCLUDED_STATUSES = ['trialing', 'paused'].freeze
+ CHURNED_STATUSES = ['canceled', 'ended'].freeze
+
class << self
include ActionView::Helpers::NumberHelper
include Profitable::JsonHelpers
@@ -239,8 +247,28 @@ def time_to_next_mrr_milestone
"#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
end
+ def monthly_summary(months: 12)
+ calculate_monthly_summary(months)
+ end
+
+ def daily_summary(days: 30)
+ calculate_daily_summary(days)
+ end
+
+ def period_data(in_the_last: DEFAULT_PERIOD)
+ calculate_period_data(in_the_last)
+ end
+
private
+ # Helper to load subscriptions with processor info from customer
+ def subscriptions_with_processor(scope = Pay::Subscription.all)
+ scope
+ .includes(:customer)
+ .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
+ .joins(:customer)
+ end
+
def paid_charges
# Pay gem v10+ stores charge data in `object` column, older versions used `data`
# We check both columns for backwards compatibility using database-agnostic JSON extraction
@@ -295,63 +323,19 @@ def parse_multiplier(input)
end
def calculate_churn(period = DEFAULT_PERIOD)
- start_date = period.ago
-
- # Count subscribers who were active AT the start of the period
- total_subscribers_start = Pay::Subscription
- .where('pay_subscriptions.created_at < ?', start_date)
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', start_date)
- .where.not(status: ['trialing', 'paused'])
- .distinct
- .count('customer_id')
-
- churned = calculate_churned_customers(period)
- return 0 if total_subscribers_start == 0
- (churned.to_f / total_subscribers_start * 100).round(2)
- end
-
- def churned_subscriptions(period = DEFAULT_PERIOD)
- Pay::Subscription
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .where(status: ['canceled', 'ended'])
- .where(ends_at: period.ago..Time.current)
+ calculate_churn_rate_for_period(period.ago, Time.current)
end
def calculate_churned_customers(period = DEFAULT_PERIOD)
- churned_subscriptions(period).distinct.count('customer_id')
+ calculate_churned_subscribers_in_period(period.ago, Time.current)
end
def calculate_churned_mrr(period = DEFAULT_PERIOD)
- start_date = period.ago
- end_date = Time.current
-
- Pay::Subscription
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .where(status: ['canceled', 'ended'])
- .where(ends_at: start_date..end_date)
- .sum do |subscription|
- MrrCalculator.process_subscription(subscription)
- end
+ calculate_churned_mrr_in_period(period.ago, Time.current)
end
def calculate_new_mrr(period = DEFAULT_PERIOD)
- start_date = period.ago
- end_date = Time.current
-
- Pay::Subscription
- .active
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .where(created_at: start_date..end_date)
- .where.not(status: ['trialing', 'paused'])
- .sum do |subscription|
- MrrCalculator.process_subscription(subscription)
- end
+ calculate_new_mrr_in_period(period.ago, Time.current)
end
def calculate_revenue_in_period(period)
@@ -404,10 +388,7 @@ def calculate_new_customers(period)
end
def calculate_new_subscribers(period)
- Pay::Customer.joins(:subscriptions)
- .where(pay_subscriptions: { created_at: period.ago..Time.current })
- .distinct
- .count
+ calculate_new_subscribers_in_period(period.ago, Time.current)
end
def calculate_average_revenue_per_customer
@@ -417,14 +398,16 @@ def calculate_average_revenue_per_customer
end
def calculate_lifetime_value
+ # LTV = Monthly ARPU / Monthly Churn Rate
+ # where ARPU (Average Revenue Per User) = MRR / active subscribers
subscribers = calculate_active_subscribers
return 0 if subscribers.zero?
- monthly_arpu = mrr.to_f / subscribers
- churn_rate = churn.to_f / 100
+ monthly_arpu = mrr.to_f / subscribers # in cents
+ churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05)
return 0 if churn_rate.zero?
- (monthly_arpu / churn_rate).round
+ (monthly_arpu / churn_rate).round # LTV in cents
end
def calculate_mrr_growth(period = DEFAULT_PERIOD)
@@ -445,18 +428,220 @@ def calculate_mrr_growth_rate(period = DEFAULT_PERIOD)
end
def calculate_mrr_at(date)
+ # Find subscriptions that were active AT the given date:
+ # - Created before or on that date
+ # - Not ended before that date (ends_at is nil OR ends_at > date)
+ # - Not paused at that date
+ # - Not in trialing status (trials don't count as MRR)
+ subscriptions_with_processor(
+ Pay::Subscription
+ .where('pay_subscriptions.created_at <= ?', date)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
+ .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
+ .where.not(status: EXCLUDED_STATUSES)
+ ).sum do |subscription|
+ MrrCalculator.process_subscription(subscription)
+ end
+ end
+
+ def calculate_period_data(period)
+ period_start = period.ago
+ period_end = Time.current
+
+ new_customers_count = actual_customers.where(created_at: period_start..period_end).count
+ churned_count = calculate_churned_subscribers_in_period(period_start, period_end)
+ new_mrr_val = calculate_new_mrr_in_period(period_start, period_end)
+ churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end)
+ revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount)
+
+ # Churn rate (reuses churned_count)
+ total_at_start = Pay::Subscription
+ .where('pay_subscriptions.created_at < ?', period_start)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
+ .where.not(status: EXCLUDED_STATUSES)
+ .distinct
+ .count('customer_id')
+ churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
+
+ {
+ new_customers: NumericResult.new(new_customers_count, :integer),
+ churned_customers: NumericResult.new(churned_count, :integer),
+ churn: NumericResult.new(churn_rate, :percentage),
+ new_mrr: NumericResult.new(new_mrr_val),
+ churned_mrr: NumericResult.new(churned_mrr_val),
+ mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val),
+ revenue: NumericResult.new(revenue_val)
+ }
+ end
+
+ # Batched: loads all data in 5 queries then groups by month in Ruby
+ def calculate_monthly_summary(months_count)
+ overall_start = (months_count - 1).months.ago.beginning_of_month
+ overall_end = Time.current.end_of_month
+
+ # Bulk load all data for the full range
+ new_sub_records = Pay::Subscription
+ .where(created_at: overall_start..overall_end)
+ .where.not(status: EXCLUDED_STATUSES)
+ .pluck(:customer_id, :created_at)
+
+ churned_sub_records = Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: overall_start..overall_end)
+ .pluck(:customer_id, :ends_at)
+
+ new_mrr_subs = subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: 'active')
+ .where(created_at: overall_start..overall_end)
+ ).to_a
+
+ churned_mrr_subs = subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: overall_start..overall_end)
+ ).to_a
+
+ churn_base_records = Pay::Subscription
+ .where('pay_subscriptions.created_at < ?', overall_end)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start)
+ .where.not(status: EXCLUDED_STATUSES)
+ .pluck(:customer_id, :created_at, :ends_at)
+
+ # Group by month in Ruby
+ summary = []
+ (months_count - 1).downto(0) do |months_ago|
+ month_start = months_ago.months.ago.beginning_of_month
+ month_end = month_start.end_of_month
+
+ new_count = new_sub_records
+ .select { |_, created_at| created_at >= month_start && created_at <= month_end }
+ .map(&:first).uniq.count
+
+ churned_count = churned_sub_records
+ .select { |_, ends_at| ends_at >= month_start && ends_at <= month_end }
+ .map(&:first).uniq.count
+
+ new_mrr_amount = new_mrr_subs
+ .select { |s| s.created_at >= month_start && s.created_at <= month_end }
+ .sum { |s| MrrCalculator.process_subscription(s) }
+
+ churned_mrr_amount = churned_mrr_subs
+ .select { |s| s.ends_at >= month_start && s.ends_at <= month_end }
+ .sum { |s| MrrCalculator.process_subscription(s) }
+
+ total_at_start = churn_base_records
+ .select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) }
+ .map(&:first).uniq.count
+
+ churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
+
+ summary << {
+ month: month_start.strftime('%Y-%m'),
+ month_date: month_start,
+ new_subscribers: new_count,
+ churned_subscribers: churned_count,
+ net_subscribers: new_count - churned_count,
+ new_mrr: new_mrr_amount,
+ churned_mrr: churned_mrr_amount,
+ net_mrr: new_mrr_amount - churned_mrr_amount,
+ churn_rate: churn_rate
+ }
+ end
+
+ summary
+ end
+
+ # Batched: loads all data in 2 queries then groups by day in Ruby
+ def calculate_daily_summary(days_count)
+ overall_start = (days_count - 1).days.ago.beginning_of_day
+ overall_end = Time.current.end_of_day
+
+ new_sub_records = Pay::Subscription
+ .where(created_at: overall_start..overall_end)
+ .where.not(status: EXCLUDED_STATUSES)
+ .pluck(:customer_id, :created_at)
+
+ churned_sub_records = Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: overall_start..overall_end)
+ .pluck(:customer_id, :ends_at)
+
+ summary = []
+ (days_count - 1).downto(0) do |days_ago|
+ day_start = days_ago.days.ago.beginning_of_day
+ day_end = day_start.end_of_day
+
+ new_count = new_sub_records
+ .select { |_, created_at| created_at >= day_start && created_at <= day_end }
+ .map(&:first).uniq.count
+
+ churned_count = churned_sub_records
+ .select { |_, ends_at| ends_at >= day_start && ends_at <= day_end }
+ .map(&:first).uniq.count
+
+ summary << {
+ date: day_start.to_date,
+ new_subscribers: new_count,
+ churned_subscribers: churned_count
+ }
+ end
+
+ summary
+ end
+
+ # Consolidated methods that work with any date range
+ def calculate_new_subscribers_in_period(period_start, period_end)
+ Pay::Customer.joins(:subscriptions)
+ .where(pay_subscriptions: { created_at: period_start..period_end })
+ .where.not(pay_subscriptions: { status: EXCLUDED_STATUSES })
+ .distinct
+ .count
+ end
+
+ def calculate_churned_subscribers_in_period(period_start, period_end)
Pay::Subscription
- .where('pay_subscriptions.created_at <= ?', date)
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
- .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
- .where.not(status: ['trialing', 'paused'])
- .includes(:customer)
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
- .joins(:customer)
- .sum do |subscription|
- MrrCalculator.process_subscription(subscription)
- end
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: period_start..period_end)
+ .distinct
+ .count('customer_id')
+ end
+
+ def calculate_new_mrr_in_period(period_start, period_end)
+ subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: 'active')
+ .where(created_at: period_start..period_end)
+ ).sum do |subscription|
+ MrrCalculator.process_subscription(subscription)
+ end
+ end
+
+ def calculate_churned_mrr_in_period(period_start, period_end)
+ subscriptions_with_processor(
+ Pay::Subscription
+ .where(status: CHURNED_STATUSES)
+ .where(ends_at: period_start..period_end)
+ ).sum do |subscription|
+ MrrCalculator.process_subscription(subscription)
+ end
end
+
+ def calculate_churn_rate_for_period(period_start, period_end)
+ # Count subscribers who were active AT the start of the period
+ total_subscribers_start = Pay::Subscription
+ .where('pay_subscriptions.created_at < ?', period_start)
+ .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
+ .where.not(status: EXCLUDED_STATUSES)
+ .distinct
+ .count('customer_id')
+
+ churned = calculate_churned_subscribers_in_period(period_start, period_end)
+ return 0 if total_subscribers_start == 0
+
+ (churned.to_f / total_subscribers_start * 100).round(1)
+ end
+
end
end