profitable 0.3.0 → 0.4.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 +47 -0
- data/AGENTS.md +5 -0
- data/Appraisals +19 -0
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +5 -0
- data/README.md +7 -4
- data/app/controllers/profitable/dashboard_controller.rb +16 -4
- data/app/views/profitable/dashboard/index.html.erb +151 -18
- data/gemfiles/pay_10.0.gemfile +9 -8
- data/gemfiles/pay_11.0.gemfile +9 -8
- data/gemfiles/pay_7.3.gemfile +9 -8
- data/gemfiles/pay_8.3.gemfile +9 -8
- data/gemfiles/pay_9.0.gemfile +9 -8
- data/gemfiles/rails_7.2.gemfile +23 -0
- data/gemfiles/rails_8.1.gemfile +23 -0
- data/lib/profitable/mrr_calculator.rb +1 -1
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +235 -69
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f24a9849fbf5f3d6b87beaa3f0a4100d18a45ccb2b585e8e48b417d303759593
|
|
4
|
+
data.tar.gz: 7c8523a9547f30468ee7517a92af604aa66c7bcc269e6292200d8722f83c30ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b79c94e7288c859f7419a8afe27c4352db1be8cff2c269b9d9d57348761a59e5f3a48cfe4b601496a1d4642b88fd5e2e5f349a1733f133488bb47a153b6a453
|
|
7
|
+
data.tar.gz: 216578eb9882a946fed3818c7d9301449727cd2b1bc64b282e90e1d8cf056b2f05ff801e49748ec46cf89a008771d796ec65daf9ce99d7ed058da5fa0d284e01
|
data/.simplecov
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SimpleCov configuration file (auto-loaded before test suite)
|
|
4
|
+
# This keeps test_helper.rb clean and follows best practices
|
|
5
|
+
|
|
6
|
+
SimpleCov.start do
|
|
7
|
+
# Use SimpleFormatter for terminal-only output (no HTML generation)
|
|
8
|
+
formatter SimpleCov::Formatter::SimpleFormatter
|
|
9
|
+
|
|
10
|
+
# Track coverage for the lib directory (gem source code)
|
|
11
|
+
add_filter "/test/"
|
|
12
|
+
|
|
13
|
+
# Exclude Rails engine components that require integration testing
|
|
14
|
+
# These are tested via Appraisal with a full Rails app
|
|
15
|
+
add_filter "/lib/profitable/engine.rb"
|
|
16
|
+
add_filter "/app/"
|
|
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.
|
|
22
|
+
add_filter "/lib/profitable.rb"
|
|
23
|
+
|
|
24
|
+
# Track the lib directory (core gem logic)
|
|
25
|
+
track_files "lib/**/*.rb"
|
|
26
|
+
|
|
27
|
+
# Enable branch coverage for more detailed metrics
|
|
28
|
+
enable_coverage :branch
|
|
29
|
+
|
|
30
|
+
# Set minimum coverage threshold for the core calculation logic
|
|
31
|
+
# The Rails engine/controllers are tested separately via Appraisal
|
|
32
|
+
minimum_coverage line: 80, branch: 70
|
|
33
|
+
|
|
34
|
+
# Disambiguate parallel test runs
|
|
35
|
+
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Print coverage summary to terminal after tests complete
|
|
39
|
+
SimpleCov.at_exit do
|
|
40
|
+
SimpleCov.result.format!
|
|
41
|
+
puts "\n" + "=" * 60
|
|
42
|
+
puts "COVERAGE SUMMARY"
|
|
43
|
+
puts "=" * 60
|
|
44
|
+
puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
|
|
45
|
+
puts "Branch Coverage: #{SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || 'N/A'}%"
|
|
46
|
+
puts "=" * 60
|
|
47
|
+
end
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/Appraisals
CHANGED
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Test minimum supported Rails version (with latest Pay)
|
|
4
|
+
appraise "rails-7.2" do
|
|
5
|
+
gem "rails", "~> 7.2.0"
|
|
6
|
+
gem "pay", "~> 11.0"
|
|
7
|
+
gem "stripe", "~> 18.0"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Test latest Rails version (with latest Pay)
|
|
11
|
+
appraise "rails-8.1" do
|
|
12
|
+
gem "rails", "~> 8.1.0"
|
|
13
|
+
gem "pay", "~> 11.0"
|
|
14
|
+
gem "stripe", "~> 18.0"
|
|
15
|
+
end
|
|
16
|
+
|
|
3
17
|
# Test against Pay 7.x (original minimum supported version)
|
|
4
18
|
appraise "pay-7.3" do
|
|
5
19
|
gem "pay", "~> 7.3.0"
|
|
6
20
|
gem "stripe", "~> 12.0"
|
|
21
|
+
gem "rails", "~> 8.1.0"
|
|
7
22
|
end
|
|
8
23
|
|
|
9
24
|
# Test against Pay 8.x
|
|
10
25
|
appraise "pay-8.3" do
|
|
11
26
|
gem "pay", "~> 8.3.0"
|
|
12
27
|
gem "stripe", "~> 13.0"
|
|
28
|
+
gem "rails", "~> 8.1.0"
|
|
13
29
|
end
|
|
14
30
|
|
|
15
31
|
# Test against Pay 9.x
|
|
16
32
|
appraise "pay-9.0" do
|
|
17
33
|
gem "pay", "~> 9.0.0"
|
|
18
34
|
gem "stripe", "~> 13.0"
|
|
35
|
+
gem "rails", "~> 8.1.0"
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
# Test against Pay 10.x (newly supported version with object column)
|
|
22
39
|
appraise "pay-10.0" do
|
|
23
40
|
gem "pay", "~> 10.0.0"
|
|
24
41
|
gem "stripe", "~> 15.0"
|
|
42
|
+
gem "rails", "~> 8.1.0"
|
|
25
43
|
end
|
|
26
44
|
|
|
27
45
|
# Test against Pay 11.x (latest version as of 2025)
|
|
28
46
|
appraise "pay-11.0" do
|
|
29
47
|
gem "pay", "~> 11.0"
|
|
30
48
|
gem "stripe", "~> 18.0"
|
|
49
|
+
gem "rails", "~> 8.1.0"
|
|
31
50
|
end
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# `profitable`
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-02-10
|
|
4
|
+
- Add monthly summary (12mo) and daily summary (30d) tables to dashboard
|
|
5
|
+
- Add `period_data` method for efficient batch computation of period metrics
|
|
6
|
+
- Fix `new_mrr` counting incomplete/unpaid subscriptions (now only counts active)
|
|
7
|
+
- Fix `new_subscribers` not filtering out trialing/paused subscriptions
|
|
8
|
+
- DRY up period methods (churn, churned_customers, new_mrr, etc.) via `_in_period` delegation
|
|
9
|
+
- Optimize dashboard from ~176 to 38 queries (batch summary queries, precompute in controller)
|
|
10
|
+
|
|
3
11
|
## [0.3.0] - 2026-01-01
|
|
4
12
|
- Add Pay v10+ support, comprehensive Minitest test suite, and 16 critical bugfixes re: wrong calculations
|
|
5
13
|
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# 💸 `profitable` - SaaS metrics for your Rails app
|
|
1
|
+
# 💸 `profitable` - MRR dashboard & SaaS metrics for your Rails app
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/profitable)
|
|
3
|
+
[](https://badge.fury.io/rb/profitable) [](https://github.com/rameerez/profitable/actions)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=profitable)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=profitable)!
|
|
7
|
+
|
|
8
|
+
`profitable` allows you to calculate the MRR, ARR, churn, LTV, ARPU, total revenue & estimated valuation of your `pay`-powered Rails SaaS app, and display them in a simple dashboard. It also provides handy methods you can use independently if you don't want the full dashboard.
|
|
6
9
|
|
|
7
10
|

|
|
8
11
|
|
|
@@ -12,7 +15,7 @@ Calculate the MRR, ARR, churn, LTV, ARPU, total revenue & estimated valuation of
|
|
|
12
15
|
|
|
13
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.)
|
|
14
17
|
|
|
15
|
-
Think doing something like: `"
|
|
18
|
+
Think doing something like: `"Current MRR: $#{Profitable.mrr}"` or `"Your app is worth $#{Profitable.valuation_estimate("3x")} at a 3x valuation"`
|
|
16
19
|
|
|
17
20
|
## Installation
|
|
18
21
|
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
module Profitable
|
|
2
2
|
class DashboardController < BaseController
|
|
3
3
|
def index
|
|
4
|
-
|
|
4
|
+
@mrr = Profitable.mrr
|
|
5
|
+
@mrr_growth_rate = Profitable.mrr_growth_rate
|
|
6
|
+
@total_customers = Profitable.total_customers
|
|
7
|
+
@all_time_revenue = Profitable.all_time_revenue
|
|
8
|
+
@estimated_valuation = Profitable.estimated_valuation
|
|
9
|
+
@average_revenue_per_customer = Profitable.average_revenue_per_customer
|
|
10
|
+
@lifetime_value = Profitable.lifetime_value
|
|
5
11
|
|
|
6
|
-
|
|
12
|
+
@show_milestone = @mrr_growth_rate > 0
|
|
13
|
+
@milestone_message = Profitable.time_to_next_mrr_milestone if @show_milestone
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
@monthly_summary = Profitable.monthly_summary(months: 12)
|
|
16
|
+
@daily_summary = Profitable.daily_summary(days: 30)
|
|
10
17
|
|
|
18
|
+
@periods = [24.hours, 7.days, 30.days]
|
|
19
|
+
@period_data = @periods.each_with_object({}) do |period, hash|
|
|
20
|
+
hash[period] = Profitable.period_data(in_the_last: period)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
11
23
|
end
|
|
12
24
|
end
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
<style>
|
|
2
|
+
main h2.title {
|
|
3
|
+
font-size: 1.25rem;
|
|
4
|
+
margin: 64px 0 12px 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
.card-grid {
|
|
3
8
|
display: flex;
|
|
4
9
|
flex-wrap: wrap;
|
|
@@ -32,8 +37,8 @@
|
|
|
32
37
|
|
|
33
38
|
<header>
|
|
34
39
|
<h1>💸 <%= Rails.application.class.module_parent_name %></h1>
|
|
35
|
-
<% if
|
|
36
|
-
<p><%=
|
|
40
|
+
<% if @show_milestone %>
|
|
41
|
+
<p><%= @milestone_message %></p>
|
|
37
42
|
<% end %>
|
|
38
43
|
</header>
|
|
39
44
|
|
|
@@ -41,69 +46,197 @@
|
|
|
41
46
|
|
|
42
47
|
<div class="card-grid">
|
|
43
48
|
<div class="card">
|
|
44
|
-
<h2><%=
|
|
49
|
+
<h2><%= @total_customers.to_readable %></h2>
|
|
45
50
|
<p>total customers</p>
|
|
46
51
|
</div>
|
|
47
52
|
<div class="card">
|
|
48
|
-
<h2><%=
|
|
53
|
+
<h2><%= @mrr.to_readable %></h2>
|
|
49
54
|
<p>MRR</p>
|
|
50
55
|
</div>
|
|
51
56
|
<div class="card">
|
|
52
|
-
<h2><%=
|
|
57
|
+
<h2><%= @estimated_valuation.to_readable %></h2>
|
|
53
58
|
<p>Valuation at 3x ARR</p>
|
|
54
59
|
</div>
|
|
55
60
|
<div class="card">
|
|
56
|
-
<h2><%=
|
|
61
|
+
<h2><%= @mrr_growth_rate.to_readable %></h2>
|
|
57
62
|
<p>MRR growth rate</p>
|
|
58
63
|
</div>
|
|
59
64
|
<div class="card">
|
|
60
|
-
<h2><%=
|
|
65
|
+
<h2><%= @average_revenue_per_customer.to_readable %></h2>
|
|
61
66
|
<p>ARPC</p>
|
|
62
67
|
</div>
|
|
63
68
|
<div class="card">
|
|
64
|
-
<h2><%=
|
|
69
|
+
<h2><%= @lifetime_value.to_readable %></h2>
|
|
65
70
|
<p>LTV</p>
|
|
66
71
|
</div>
|
|
67
72
|
<div class="card">
|
|
68
|
-
<h2><%=
|
|
73
|
+
<h2><%= @all_time_revenue.to_readable %></h2>
|
|
69
74
|
<p>All-time revenue</p>
|
|
70
75
|
</div>
|
|
71
76
|
</div>
|
|
72
77
|
|
|
73
|
-
|
|
78
|
+
<h2 class="title">Monthly summary</h2>
|
|
79
|
+
<small>(last 12 months)</small>
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
.summary-table {
|
|
83
|
+
width: 100%;
|
|
84
|
+
border-collapse: collapse;
|
|
85
|
+
margin-top: 16px;
|
|
86
|
+
background-color: var(--bg);
|
|
87
|
+
border: 1px solid var(--border);
|
|
88
|
+
border-radius: var(--standard-border-radius);
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.summary-table th,
|
|
93
|
+
.summary-table td {
|
|
94
|
+
padding: 12px 16px;
|
|
95
|
+
text-align: left;
|
|
96
|
+
border-bottom: 1px solid var(--border);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.summary-table th {
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
background-color: var(--bg);
|
|
102
|
+
position: sticky;
|
|
103
|
+
top: 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.summary-table tr:last-child td {
|
|
107
|
+
border-bottom: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.summary-table .positive {
|
|
111
|
+
color: #10b981;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.summary-table .negative {
|
|
115
|
+
color: #ef4444;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.summary-table .muted {
|
|
119
|
+
color: #6b7280;
|
|
120
|
+
font-size: 0.875em;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.summary-table tbody tr:hover {
|
|
124
|
+
background-color: rgba(0, 0, 0, 0.02);
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
127
|
+
|
|
128
|
+
<table class="summary-table">
|
|
129
|
+
<thead>
|
|
130
|
+
<tr>
|
|
131
|
+
<th>Month</th>
|
|
132
|
+
<th>New</th>
|
|
133
|
+
<th>Churned</th>
|
|
134
|
+
<th>Net</th>
|
|
135
|
+
<th>Churn %</th>
|
|
136
|
+
</tr>
|
|
137
|
+
</thead>
|
|
138
|
+
<tbody>
|
|
139
|
+
<% @monthly_summary.reverse.each do |month_data| %>
|
|
140
|
+
<tr>
|
|
141
|
+
<td><strong><%= month_data[:month_date].strftime('%b %Y') %></strong></td>
|
|
142
|
+
<td>
|
|
143
|
+
<span class="positive">+<%= month_data[:new_subscribers] %></span>
|
|
144
|
+
<span class="muted">(~<%= number_to_currency(month_data[:new_mrr] / 100.0, precision: 0) %>)</span>
|
|
145
|
+
</td>
|
|
146
|
+
<td>
|
|
147
|
+
<% if month_data[:churned_subscribers] > 0 %>
|
|
148
|
+
<span class="negative">-<%= month_data[:churned_subscribers] %></span>
|
|
149
|
+
<span class="muted">(~<%= number_to_currency(month_data[:churned_mrr] / 100.0, precision: 0) %>)</span>
|
|
150
|
+
<% else %>
|
|
151
|
+
<span>0</span>
|
|
152
|
+
<% end %>
|
|
153
|
+
</td>
|
|
154
|
+
<td>
|
|
155
|
+
<% if month_data[:net_subscribers] > 0 %>
|
|
156
|
+
<span class="positive">+<%= month_data[:net_subscribers] %></span>
|
|
157
|
+
<span class="muted">(~+<%= number_to_currency(month_data[:net_mrr] / 100.0, precision: 0) %>)</span>
|
|
158
|
+
<% elsif month_data[:net_subscribers] < 0 %>
|
|
159
|
+
<span class="negative"><%= month_data[:net_subscribers] %></span>
|
|
160
|
+
<span class="muted">(~<%= number_to_currency(month_data[:net_mrr] / 100.0, precision: 0) %>)</span>
|
|
161
|
+
<% else %>
|
|
162
|
+
<span>0</span>
|
|
163
|
+
<% end %>
|
|
164
|
+
</td>
|
|
165
|
+
<td><%= month_data[:churn_rate] %>%</td>
|
|
166
|
+
</tr>
|
|
167
|
+
<% end %>
|
|
168
|
+
</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
|
|
171
|
+
<h2 class="title">Daily summary</h2>
|
|
172
|
+
<small>(last 30 days)</small>
|
|
173
|
+
|
|
174
|
+
<table class="summary-table">
|
|
175
|
+
<thead>
|
|
176
|
+
<tr>
|
|
177
|
+
<th>Date</th>
|
|
178
|
+
<th>New Subscribers</th>
|
|
179
|
+
<th>Churned</th>
|
|
180
|
+
</tr>
|
|
181
|
+
</thead>
|
|
182
|
+
<tbody>
|
|
183
|
+
<% @daily_summary.reverse.each do |day_data| %>
|
|
184
|
+
<tr>
|
|
185
|
+
<td><strong><%= day_data[:date].strftime('%b %-d, %Y') %></strong></td>
|
|
186
|
+
<td>
|
|
187
|
+
<% if day_data[:new_subscribers] > 0 %>
|
|
188
|
+
<span class="positive">+<%= day_data[:new_subscribers] %></span>
|
|
189
|
+
<% else %>
|
|
190
|
+
<span>0</span>
|
|
191
|
+
<% end %>
|
|
192
|
+
</td>
|
|
193
|
+
<td>
|
|
194
|
+
<% if day_data[:churned_subscribers] > 0 %>
|
|
195
|
+
<span class="negative">-<%= day_data[:churned_subscribers] %></span>
|
|
196
|
+
<% else %>
|
|
197
|
+
<span>0</span>
|
|
198
|
+
<% end %>
|
|
199
|
+
</td>
|
|
200
|
+
</tr>
|
|
201
|
+
<% end %>
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
|
|
205
|
+
<% @periods.each do |period| %>
|
|
74
206
|
<% period_short = period.inspect.gsub("days", "d").gsub("hours", "h").gsub(" ", "") %>
|
|
207
|
+
<% data = @period_data[period] %>
|
|
75
208
|
|
|
76
|
-
<h2>Last <%= period.inspect %></h2>
|
|
209
|
+
<h2 class="title">Last <%= period.inspect %></h2>
|
|
77
210
|
|
|
78
211
|
<div class="card-grid">
|
|
79
212
|
<div class="card">
|
|
80
|
-
<h2><%=
|
|
213
|
+
<h2><%= data[:new_customers].to_readable %></h2>
|
|
81
214
|
<p>new customers (<%= period_short %>)</p>
|
|
82
215
|
</div>
|
|
83
216
|
<div class="card">
|
|
84
|
-
<h2><%=
|
|
217
|
+
<h2><%= data[:churned_customers].to_readable %></h2>
|
|
85
218
|
<p>churned customers (<%= period_short %>)</p>
|
|
86
219
|
</div>
|
|
87
220
|
<div class="card">
|
|
88
|
-
<h2><%=
|
|
221
|
+
<h2><%= data[:churn].to_readable %></h2>
|
|
89
222
|
<p>churn (<%= period_short %>)</p>
|
|
90
223
|
</div>
|
|
91
224
|
|
|
92
225
|
<div class="card">
|
|
93
|
-
<h2><%=
|
|
226
|
+
<h2><%= data[:new_mrr].to_readable %></h2>
|
|
94
227
|
<p>new MRR (<%= period_short %>)</p>
|
|
95
228
|
</div>
|
|
96
229
|
<div class="card">
|
|
97
|
-
<h2><%=
|
|
230
|
+
<h2><%= data[:churned_mrr].to_readable %></h2>
|
|
98
231
|
<p>churned MRR (<%= period_short %>)</p>
|
|
99
232
|
</div>
|
|
100
233
|
<div class="card">
|
|
101
|
-
<h2><%=
|
|
234
|
+
<h2><%= data[:mrr_growth].to_readable %></h2>
|
|
102
235
|
<p>MRR growth (<%= period_short %>)</p>
|
|
103
236
|
</div>
|
|
104
237
|
|
|
105
238
|
<div class="card">
|
|
106
|
-
<h2><%=
|
|
239
|
+
<h2><%= data[:revenue].to_readable %></h2>
|
|
107
240
|
<p>total revenue (<%= period_short %>)</p>
|
|
108
241
|
</div>
|
|
109
242
|
|
data/gemfiles/pay_10.0.gemfile
CHANGED
|
@@ -5,18 +5,19 @@ source "https://rubygems.org"
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
6
|
gem "pay", "~> 10.0.0"
|
|
7
7
|
gem "stripe", "~> 15.0"
|
|
8
|
+
gem "rails", "~> 8.1.0"
|
|
8
9
|
|
|
9
|
-
group :development do
|
|
10
|
-
gem "appraisal"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
gem "
|
|
15
|
-
gem "
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
16
18
|
gem "mocha", "~> 2.1"
|
|
17
19
|
gem "activerecord", ">= 7.0"
|
|
18
20
|
gem "actionview", ">= 7.0"
|
|
19
|
-
gem "sqlite3"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
gemspec path: "../"
|
data/gemfiles/pay_11.0.gemfile
CHANGED
|
@@ -5,18 +5,19 @@ source "https://rubygems.org"
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
6
|
gem "pay", "~> 11.0"
|
|
7
7
|
gem "stripe", "~> 18.0"
|
|
8
|
+
gem "rails", "~> 8.1.0"
|
|
8
9
|
|
|
9
|
-
group :development do
|
|
10
|
-
gem "appraisal"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
gem "
|
|
15
|
-
gem "
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
16
18
|
gem "mocha", "~> 2.1"
|
|
17
19
|
gem "activerecord", ">= 7.0"
|
|
18
20
|
gem "actionview", ">= 7.0"
|
|
19
|
-
gem "sqlite3"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
gemspec path: "../"
|
data/gemfiles/pay_7.3.gemfile
CHANGED
|
@@ -5,18 +5,19 @@ source "https://rubygems.org"
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
6
|
gem "pay", "~> 7.3.0"
|
|
7
7
|
gem "stripe", "~> 12.0"
|
|
8
|
+
gem "rails", "~> 8.1.0"
|
|
8
9
|
|
|
9
|
-
group :development do
|
|
10
|
-
gem "appraisal"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
gem "
|
|
15
|
-
gem "
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
16
18
|
gem "mocha", "~> 2.1"
|
|
17
19
|
gem "activerecord", ">= 7.0"
|
|
18
20
|
gem "actionview", ">= 7.0"
|
|
19
|
-
gem "sqlite3"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
gemspec path: "../"
|
data/gemfiles/pay_8.3.gemfile
CHANGED
|
@@ -5,18 +5,19 @@ source "https://rubygems.org"
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
6
|
gem "pay", "~> 8.3.0"
|
|
7
7
|
gem "stripe", "~> 13.0"
|
|
8
|
+
gem "rails", "~> 8.1.0"
|
|
8
9
|
|
|
9
|
-
group :development do
|
|
10
|
-
gem "appraisal"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
gem "
|
|
15
|
-
gem "
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
16
18
|
gem "mocha", "~> 2.1"
|
|
17
19
|
gem "activerecord", ">= 7.0"
|
|
18
20
|
gem "actionview", ">= 7.0"
|
|
19
|
-
gem "sqlite3"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
gemspec path: "../"
|
data/gemfiles/pay_9.0.gemfile
CHANGED
|
@@ -5,18 +5,19 @@ source "https://rubygems.org"
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
6
|
gem "pay", "~> 9.0.0"
|
|
7
7
|
gem "stripe", "~> 13.0"
|
|
8
|
+
gem "rails", "~> 8.1.0"
|
|
8
9
|
|
|
9
|
-
group :development do
|
|
10
|
-
gem "appraisal"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
gem "
|
|
15
|
-
gem "
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
16
18
|
gem "mocha", "~> 2.1"
|
|
17
19
|
gem "activerecord", ">= 7.0"
|
|
18
20
|
gem "actionview", ">= 7.0"
|
|
19
|
-
gem "sqlite3"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
gemspec path: "../"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.2.0"
|
|
7
|
+
gem "pay", "~> 11.0"
|
|
8
|
+
gem "stripe", "~> 18.0"
|
|
9
|
+
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
18
|
+
gem "mocha", "~> 2.1"
|
|
19
|
+
gem "activerecord", ">= 7.0"
|
|
20
|
+
gem "actionview", ">= 7.0"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 8.1.0"
|
|
7
|
+
gem "pay", "~> 11.0"
|
|
8
|
+
gem "stripe", "~> 18.0"
|
|
9
|
+
|
|
10
|
+
group :development, :test do
|
|
11
|
+
gem "appraisal"
|
|
12
|
+
gem "minitest", "~> 6.0"
|
|
13
|
+
gem "minitest-mock"
|
|
14
|
+
gem "minitest-reporters"
|
|
15
|
+
gem "rack-test"
|
|
16
|
+
gem "simplecov", require: false
|
|
17
|
+
gem "sqlite3", ">= 2.1"
|
|
18
|
+
gem "mocha", "~> 2.1"
|
|
19
|
+
gem "activerecord", ">= 7.0"
|
|
20
|
+
gem "actionview", ">= 7.0"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
gemspec path: "../"
|
|
@@ -10,7 +10,7 @@ module Profitable
|
|
|
10
10
|
total_mrr = 0
|
|
11
11
|
subscriptions = Pay::Subscription
|
|
12
12
|
.active
|
|
13
|
-
.where.not(status:
|
|
13
|
+
.where.not(status: Profitable::EXCLUDED_STATUSES)
|
|
14
14
|
.includes(:customer)
|
|
15
15
|
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
16
16
|
.joins(:customer)
|
data/lib/profitable/version.rb
CHANGED
data/lib/profitable.rb
CHANGED
|
@@ -13,6 +13,10 @@ require "active_support/core_ext/numeric/conversions"
|
|
|
13
13
|
require "action_view"
|
|
14
14
|
|
|
15
15
|
module Profitable
|
|
16
|
+
# Subscription status constants (at module level so MrrCalculator can reference them)
|
|
17
|
+
EXCLUDED_STATUSES = ['trialing', 'paused'].freeze
|
|
18
|
+
CHURNED_STATUSES = ['canceled', 'ended'].freeze
|
|
19
|
+
|
|
16
20
|
class << self
|
|
17
21
|
include ActionView::Helpers::NumberHelper
|
|
18
22
|
include Profitable::JsonHelpers
|
|
@@ -123,8 +127,28 @@ module Profitable
|
|
|
123
127
|
"#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
|
|
124
128
|
end
|
|
125
129
|
|
|
130
|
+
def monthly_summary(months: 12)
|
|
131
|
+
calculate_monthly_summary(months)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def daily_summary(days: 30)
|
|
135
|
+
calculate_daily_summary(days)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def period_data(in_the_last: DEFAULT_PERIOD)
|
|
139
|
+
calculate_period_data(in_the_last)
|
|
140
|
+
end
|
|
141
|
+
|
|
126
142
|
private
|
|
127
143
|
|
|
144
|
+
# Helper to load subscriptions with processor info from customer
|
|
145
|
+
def subscriptions_with_processor(scope = Pay::Subscription.all)
|
|
146
|
+
scope
|
|
147
|
+
.includes(:customer)
|
|
148
|
+
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
149
|
+
.joins(:customer)
|
|
150
|
+
end
|
|
151
|
+
|
|
128
152
|
def paid_charges
|
|
129
153
|
# Pay gem v10+ stores charge data in `object` column, older versions used `data`
|
|
130
154
|
# We check both columns for backwards compatibility using database-agnostic JSON extraction
|
|
@@ -184,68 +208,19 @@ module Profitable
|
|
|
184
208
|
end
|
|
185
209
|
|
|
186
210
|
def calculate_churn(period = DEFAULT_PERIOD)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
# Count subscribers who were active AT the start of the period
|
|
190
|
-
# (not just currently active, but active at that historical point)
|
|
191
|
-
total_subscribers_start = Pay::Subscription
|
|
192
|
-
.where('pay_subscriptions.created_at < ?', start_date)
|
|
193
|
-
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', start_date)
|
|
194
|
-
.where.not(status: ['trialing', 'paused'])
|
|
195
|
-
.distinct
|
|
196
|
-
.count('customer_id')
|
|
197
|
-
|
|
198
|
-
churned = calculate_churned_customers(period)
|
|
199
|
-
return 0 if total_subscribers_start == 0
|
|
200
|
-
(churned.to_f / total_subscribers_start * 100).round(2)
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def churned_subscriptions(period = DEFAULT_PERIOD)
|
|
204
|
-
Pay::Subscription
|
|
205
|
-
.includes(:customer)
|
|
206
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
207
|
-
.joins(:customer)
|
|
208
|
-
.where(status: ['canceled', 'ended'])
|
|
209
|
-
.where(ends_at: period.ago..Time.current)
|
|
211
|
+
calculate_churn_rate_for_period(period.ago, Time.current)
|
|
210
212
|
end
|
|
211
213
|
|
|
212
214
|
def calculate_churned_customers(period = DEFAULT_PERIOD)
|
|
213
|
-
|
|
215
|
+
calculate_churned_subscribers_in_period(period.ago, Time.current)
|
|
214
216
|
end
|
|
215
217
|
|
|
216
218
|
def calculate_churned_mrr(period = DEFAULT_PERIOD)
|
|
217
|
-
|
|
218
|
-
end_date = Time.current
|
|
219
|
-
|
|
220
|
-
# Churned MRR = full monthly rate of subscriptions that ended in the period
|
|
221
|
-
# MRR is a rate, not revenue, so we don't prorate
|
|
222
|
-
Pay::Subscription
|
|
223
|
-
.includes(:customer)
|
|
224
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
225
|
-
.joins(:customer)
|
|
226
|
-
.where(status: ['canceled', 'ended'])
|
|
227
|
-
.where(ends_at: start_date..end_date)
|
|
228
|
-
.sum do |subscription|
|
|
229
|
-
MrrCalculator.process_subscription(subscription)
|
|
230
|
-
end
|
|
219
|
+
calculate_churned_mrr_in_period(period.ago, Time.current)
|
|
231
220
|
end
|
|
232
221
|
|
|
233
222
|
def calculate_new_mrr(period = DEFAULT_PERIOD)
|
|
234
|
-
|
|
235
|
-
end_date = Time.current
|
|
236
|
-
|
|
237
|
-
# New MRR = full monthly rate of subscriptions created in the period
|
|
238
|
-
# MRR is a rate, not revenue, so we don't prorate
|
|
239
|
-
Pay::Subscription
|
|
240
|
-
.active
|
|
241
|
-
.includes(:customer)
|
|
242
|
-
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
243
|
-
.joins(:customer)
|
|
244
|
-
.where(created_at: start_date..end_date)
|
|
245
|
-
.where.not(status: ['trialing', 'paused'])
|
|
246
|
-
.sum do |subscription|
|
|
247
|
-
MrrCalculator.process_subscription(subscription)
|
|
248
|
-
end
|
|
223
|
+
calculate_new_mrr_in_period(period.ago, Time.current)
|
|
249
224
|
end
|
|
250
225
|
|
|
251
226
|
def calculate_revenue_in_period(period)
|
|
@@ -298,12 +273,7 @@ module Profitable
|
|
|
298
273
|
end
|
|
299
274
|
|
|
300
275
|
def calculate_new_subscribers(period)
|
|
301
|
-
|
|
302
|
-
# (not customers created in the period, but subscriptions created in the period)
|
|
303
|
-
Pay::Customer.joins(:subscriptions)
|
|
304
|
-
.where(pay_subscriptions: { created_at: period.ago..Time.current })
|
|
305
|
-
.distinct
|
|
306
|
-
.count
|
|
276
|
+
calculate_new_subscribers_in_period(period.ago, Time.current)
|
|
307
277
|
end
|
|
308
278
|
|
|
309
279
|
def calculate_average_revenue_per_customer
|
|
@@ -348,17 +318,213 @@ module Profitable
|
|
|
348
318
|
# - Not ended before that date (ends_at is nil OR ends_at > date)
|
|
349
319
|
# - Not paused at that date
|
|
350
320
|
# - Not in trialing status (trials don't count as MRR)
|
|
321
|
+
subscriptions_with_processor(
|
|
322
|
+
Pay::Subscription
|
|
323
|
+
.where('pay_subscriptions.created_at <= ?', date)
|
|
324
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
|
|
325
|
+
.where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
|
|
326
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
327
|
+
).sum do |subscription|
|
|
328
|
+
MrrCalculator.process_subscription(subscription)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def calculate_period_data(period)
|
|
333
|
+
period_start = period.ago
|
|
334
|
+
period_end = Time.current
|
|
335
|
+
|
|
336
|
+
new_customers_count = actual_customers.where(created_at: period_start..period_end).count
|
|
337
|
+
churned_count = calculate_churned_subscribers_in_period(period_start, period_end)
|
|
338
|
+
new_mrr_val = calculate_new_mrr_in_period(period_start, period_end)
|
|
339
|
+
churned_mrr_val = calculate_churned_mrr_in_period(period_start, period_end)
|
|
340
|
+
revenue_val = paid_charges.where(created_at: period_start..period_end).sum(:amount)
|
|
341
|
+
|
|
342
|
+
# Churn rate (reuses churned_count)
|
|
343
|
+
total_at_start = Pay::Subscription
|
|
344
|
+
.where('pay_subscriptions.created_at < ?', period_start)
|
|
345
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
|
|
346
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
347
|
+
.distinct
|
|
348
|
+
.count('customer_id')
|
|
349
|
+
churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
|
|
350
|
+
|
|
351
|
+
{
|
|
352
|
+
new_customers: NumericResult.new(new_customers_count, :integer),
|
|
353
|
+
churned_customers: NumericResult.new(churned_count, :integer),
|
|
354
|
+
churn: NumericResult.new(churn_rate, :percentage),
|
|
355
|
+
new_mrr: NumericResult.new(new_mrr_val),
|
|
356
|
+
churned_mrr: NumericResult.new(churned_mrr_val),
|
|
357
|
+
mrr_growth: NumericResult.new(new_mrr_val - churned_mrr_val),
|
|
358
|
+
revenue: NumericResult.new(revenue_val)
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Batched: loads all data in 5 queries then groups by month in Ruby
|
|
363
|
+
def calculate_monthly_summary(months_count)
|
|
364
|
+
overall_start = (months_count - 1).months.ago.beginning_of_month
|
|
365
|
+
overall_end = Time.current.end_of_month
|
|
366
|
+
|
|
367
|
+
# Bulk load all data for the full range
|
|
368
|
+
new_sub_records = Pay::Subscription
|
|
369
|
+
.where(created_at: overall_start..overall_end)
|
|
370
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
371
|
+
.pluck(:customer_id, :created_at)
|
|
372
|
+
|
|
373
|
+
churned_sub_records = Pay::Subscription
|
|
374
|
+
.where(status: CHURNED_STATUSES)
|
|
375
|
+
.where(ends_at: overall_start..overall_end)
|
|
376
|
+
.pluck(:customer_id, :ends_at)
|
|
377
|
+
|
|
378
|
+
new_mrr_subs = subscriptions_with_processor(
|
|
379
|
+
Pay::Subscription
|
|
380
|
+
.where(status: 'active')
|
|
381
|
+
.where(created_at: overall_start..overall_end)
|
|
382
|
+
).to_a
|
|
383
|
+
|
|
384
|
+
churned_mrr_subs = subscriptions_with_processor(
|
|
385
|
+
Pay::Subscription
|
|
386
|
+
.where(status: CHURNED_STATUSES)
|
|
387
|
+
.where(ends_at: overall_start..overall_end)
|
|
388
|
+
).to_a
|
|
389
|
+
|
|
390
|
+
churn_base_records = Pay::Subscription
|
|
391
|
+
.where('pay_subscriptions.created_at < ?', overall_end)
|
|
392
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', overall_start)
|
|
393
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
394
|
+
.pluck(:customer_id, :created_at, :ends_at)
|
|
395
|
+
|
|
396
|
+
# Group by month in Ruby
|
|
397
|
+
summary = []
|
|
398
|
+
(months_count - 1).downto(0) do |months_ago|
|
|
399
|
+
month_start = months_ago.months.ago.beginning_of_month
|
|
400
|
+
month_end = month_start.end_of_month
|
|
401
|
+
|
|
402
|
+
new_count = new_sub_records
|
|
403
|
+
.select { |_, created_at| created_at >= month_start && created_at <= month_end }
|
|
404
|
+
.map(&:first).uniq.count
|
|
405
|
+
|
|
406
|
+
churned_count = churned_sub_records
|
|
407
|
+
.select { |_, ends_at| ends_at >= month_start && ends_at <= month_end }
|
|
408
|
+
.map(&:first).uniq.count
|
|
409
|
+
|
|
410
|
+
new_mrr_amount = new_mrr_subs
|
|
411
|
+
.select { |s| s.created_at >= month_start && s.created_at <= month_end }
|
|
412
|
+
.sum { |s| MrrCalculator.process_subscription(s) }
|
|
413
|
+
|
|
414
|
+
churned_mrr_amount = churned_mrr_subs
|
|
415
|
+
.select { |s| s.ends_at >= month_start && s.ends_at <= month_end }
|
|
416
|
+
.sum { |s| MrrCalculator.process_subscription(s) }
|
|
417
|
+
|
|
418
|
+
total_at_start = churn_base_records
|
|
419
|
+
.select { |_, created_at, ends_at| created_at < month_start && (ends_at.nil? || ends_at > month_start) }
|
|
420
|
+
.map(&:first).uniq.count
|
|
421
|
+
|
|
422
|
+
churn_rate = total_at_start > 0 ? (churned_count.to_f / total_at_start * 100).round(1) : 0
|
|
423
|
+
|
|
424
|
+
summary << {
|
|
425
|
+
month: month_start.strftime('%Y-%m'),
|
|
426
|
+
month_date: month_start,
|
|
427
|
+
new_subscribers: new_count,
|
|
428
|
+
churned_subscribers: churned_count,
|
|
429
|
+
net_subscribers: new_count - churned_count,
|
|
430
|
+
new_mrr: new_mrr_amount,
|
|
431
|
+
churned_mrr: churned_mrr_amount,
|
|
432
|
+
net_mrr: new_mrr_amount - churned_mrr_amount,
|
|
433
|
+
churn_rate: churn_rate
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
summary
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Batched: loads all data in 2 queries then groups by day in Ruby
|
|
441
|
+
def calculate_daily_summary(days_count)
|
|
442
|
+
overall_start = (days_count - 1).days.ago.beginning_of_day
|
|
443
|
+
overall_end = Time.current.end_of_day
|
|
444
|
+
|
|
445
|
+
new_sub_records = Pay::Subscription
|
|
446
|
+
.where(created_at: overall_start..overall_end)
|
|
447
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
448
|
+
.pluck(:customer_id, :created_at)
|
|
449
|
+
|
|
450
|
+
churned_sub_records = Pay::Subscription
|
|
451
|
+
.where(status: CHURNED_STATUSES)
|
|
452
|
+
.where(ends_at: overall_start..overall_end)
|
|
453
|
+
.pluck(:customer_id, :ends_at)
|
|
454
|
+
|
|
455
|
+
summary = []
|
|
456
|
+
(days_count - 1).downto(0) do |days_ago|
|
|
457
|
+
day_start = days_ago.days.ago.beginning_of_day
|
|
458
|
+
day_end = day_start.end_of_day
|
|
459
|
+
|
|
460
|
+
new_count = new_sub_records
|
|
461
|
+
.select { |_, created_at| created_at >= day_start && created_at <= day_end }
|
|
462
|
+
.map(&:first).uniq.count
|
|
463
|
+
|
|
464
|
+
churned_count = churned_sub_records
|
|
465
|
+
.select { |_, ends_at| ends_at >= day_start && ends_at <= day_end }
|
|
466
|
+
.map(&:first).uniq.count
|
|
467
|
+
|
|
468
|
+
summary << {
|
|
469
|
+
date: day_start.to_date,
|
|
470
|
+
new_subscribers: new_count,
|
|
471
|
+
churned_subscribers: churned_count
|
|
472
|
+
}
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
summary
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Consolidated methods that work with any date range
|
|
479
|
+
def calculate_new_subscribers_in_period(period_start, period_end)
|
|
480
|
+
Pay::Customer.joins(:subscriptions)
|
|
481
|
+
.where(pay_subscriptions: { created_at: period_start..period_end })
|
|
482
|
+
.where.not(pay_subscriptions: { status: EXCLUDED_STATUSES })
|
|
483
|
+
.distinct
|
|
484
|
+
.count
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def calculate_churned_subscribers_in_period(period_start, period_end)
|
|
351
488
|
Pay::Subscription
|
|
352
|
-
.where(
|
|
353
|
-
.where(
|
|
354
|
-
.
|
|
355
|
-
.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
489
|
+
.where(status: CHURNED_STATUSES)
|
|
490
|
+
.where(ends_at: period_start..period_end)
|
|
491
|
+
.distinct
|
|
492
|
+
.count('customer_id')
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def calculate_new_mrr_in_period(period_start, period_end)
|
|
496
|
+
subscriptions_with_processor(
|
|
497
|
+
Pay::Subscription
|
|
498
|
+
.where(status: 'active')
|
|
499
|
+
.where(created_at: period_start..period_end)
|
|
500
|
+
).sum do |subscription|
|
|
501
|
+
MrrCalculator.process_subscription(subscription)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def calculate_churned_mrr_in_period(period_start, period_end)
|
|
506
|
+
subscriptions_with_processor(
|
|
507
|
+
Pay::Subscription
|
|
508
|
+
.where(status: CHURNED_STATUSES)
|
|
509
|
+
.where(ends_at: period_start..period_end)
|
|
510
|
+
).sum do |subscription|
|
|
511
|
+
MrrCalculator.process_subscription(subscription)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def calculate_churn_rate_for_period(period_start, period_end)
|
|
516
|
+
# Count subscribers who were active AT the start of the period
|
|
517
|
+
total_subscribers_start = Pay::Subscription
|
|
518
|
+
.where('pay_subscriptions.created_at < ?', period_start)
|
|
519
|
+
.where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', period_start)
|
|
520
|
+
.where.not(status: EXCLUDED_STATUSES)
|
|
521
|
+
.distinct
|
|
522
|
+
.count('customer_id')
|
|
523
|
+
|
|
524
|
+
churned = calculate_churned_subscribers_in_period(period_start, period_end)
|
|
525
|
+
return 0 if total_subscribers_start == 0
|
|
526
|
+
|
|
527
|
+
(churned.to_f / total_subscribers_start * 100).round(1)
|
|
362
528
|
end
|
|
363
529
|
|
|
364
530
|
end
|
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.4.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-02-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|
|
@@ -46,8 +46,11 @@ executables: []
|
|
|
46
46
|
extensions: []
|
|
47
47
|
extra_rdoc_files: []
|
|
48
48
|
files:
|
|
49
|
+
- ".simplecov"
|
|
50
|
+
- AGENTS.md
|
|
49
51
|
- Appraisals
|
|
50
52
|
- CHANGELOG.md
|
|
53
|
+
- CLAUDE.md
|
|
51
54
|
- LICENSE.txt
|
|
52
55
|
- README.md
|
|
53
56
|
- Rakefile
|
|
@@ -61,6 +64,8 @@ files:
|
|
|
61
64
|
- gemfiles/pay_7.3.gemfile
|
|
62
65
|
- gemfiles/pay_8.3.gemfile
|
|
63
66
|
- gemfiles/pay_9.0.gemfile
|
|
67
|
+
- gemfiles/rails_7.2.gemfile
|
|
68
|
+
- gemfiles/rails_8.1.gemfile
|
|
64
69
|
- lib/profitable.rb
|
|
65
70
|
- lib/profitable/engine.rb
|
|
66
71
|
- lib/profitable/error.rb
|