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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f5435ab1dfe3d15abb2f92e6720a9ced3f3fb60474eaf85d407b85f8306632d
4
- data.tar.gz: adc0a485926ff124fcd19bbf8c3d48eed10ac850fa459fcc9f84d78a40cddc9f
3
+ metadata.gz: f24a9849fbf5f3d6b87beaa3f0a4100d18a45ccb2b585e8e48b417d303759593
4
+ data.tar.gz: 7c8523a9547f30468ee7517a92af604aa66c7bcc269e6292200d8722f83c30ff
5
5
  SHA512:
6
- metadata.gz: 1e8d5f63b3d11b0b146b9225da6f63b85353efff1f944cc59a1ad78107e75aab4daacbbed5c46f7896edbc401790fbf9001d9424ca0fdbbe70afb4c3b2d587a2
7
- data.tar.gz: f429f34975e2c7c48e0c16579ec675059c13d82e9c1634ebdc3e29d3a327be9ceec9662957da5a2214bc1a642aa9a23fb33be4919100b553bd3ad4112583a8c9
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
- [![Gem Version](https://badge.fury.io/rb/profitable.svg)](https://badge.fury.io/rb/profitable)
3
+ [![Gem Version](https://badge.fury.io/rb/profitable.svg)](https://badge.fury.io/rb/profitable) [![Build Status](https://github.com/rameerez/profitable/workflows/Tests/badge.svg)](https://github.com/rameerez/profitable/actions)
4
4
 
5
- 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.
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
  ![Profitable gem main dashboard](profitable.webp)
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: `"Your app is currently at $#{Profitable.mrr} MRR Estimated to be worth $#{Profitable.valuation_estimate("3x")} at a 3x valuation"`
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
- end
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
- private
12
+ @show_milestone = @mrr_growth_rate > 0
13
+ @milestone_message = Profitable.time_to_next_mrr_milestone if @show_milestone
7
14
 
8
- def test
9
- end
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 Profitable.mrr_growth_rate > 0 %>
36
- <p><%= Profitable.time_to_next_mrr_milestone %></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><%= Profitable.total_customers.to_readable %></h2>
49
+ <h2><%= @total_customers.to_readable %></h2>
45
50
  <p>total customers</p>
46
51
  </div>
47
52
  <div class="card">
48
- <h2><%= Profitable.mrr.to_readable %></h2>
53
+ <h2><%= @mrr.to_readable %></h2>
49
54
  <p>MRR</p>
50
55
  </div>
51
56
  <div class="card">
52
- <h2><%= Profitable.estimated_valuation.to_readable %></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><%= Profitable.mrr_growth_rate.to_readable %></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><%= Profitable.average_revenue_per_customer.to_readable %></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><%= Profitable.lifetime_value.to_readable %></h2>
69
+ <h2><%= @lifetime_value.to_readable %></h2>
65
70
  <p>LTV</p>
66
71
  </div>
67
72
  <div class="card">
68
- <h2><%= Profitable.all_time_revenue.to_readable %></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
- <% [24.hours, 7.days, 30.days].each do |period| %>
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><%= Profitable.new_customers(in_the_last: period).to_readable %></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><%= Profitable.churned_customers(in_the_last: period).to_readable %></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><%= Profitable.churn(in_the_last: period).to_readable %></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><%= Profitable.new_mrr(in_the_last: period).to_readable %></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><%= Profitable.churned_mrr(in_the_last: period).to_readable %></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><%= Profitable.mrr_growth(in_the_last: period).to_readable %></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><%= Profitable.revenue_in_period(in_the_last: period).to_readable %></h2>
239
+ <h2><%= data[:revenue].to_readable %></h2>
107
240
  <p>total revenue (<%= period_short %>)</p>
108
241
  </div>
109
242
 
@@ -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", "~> 2.5"
11
- end
12
-
13
- group :test do
14
- gem "minitest", "~> 5.0"
15
- gem "minitest-reporters", "~> 1.6"
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: "../"
@@ -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", "~> 2.5"
11
- end
12
-
13
- group :test do
14
- gem "minitest", "~> 5.0"
15
- gem "minitest-reporters", "~> 1.6"
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: "../"
@@ -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", "~> 2.5"
11
- end
12
-
13
- group :test do
14
- gem "minitest", "~> 5.0"
15
- gem "minitest-reporters", "~> 1.6"
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: "../"
@@ -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", "~> 2.5"
11
- end
12
-
13
- group :test do
14
- gem "minitest", "~> 5.0"
15
- gem "minitest-reporters", "~> 1.6"
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: "../"
@@ -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", "~> 2.5"
11
- end
12
-
13
- group :test do
14
- gem "minitest", "~> 5.0"
15
- gem "minitest-reporters", "~> 1.6"
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: ['trialing', 'paused'])
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profitable
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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
- start_date = period.ago
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
- churned_subscriptions(period).distinct.count('customer_id')
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
- start_date = period.ago
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
- start_date = period.ago
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
- # Count customers who got a NEW subscription in the period
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('pay_subscriptions.created_at <= ?', date)
353
- .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date)
354
- .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date)
355
- .where.not(status: ['trialing', 'paused'])
356
- .includes(:customer)
357
- .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
358
- .joins(:customer)
359
- .sum do |subscription|
360
- MrrCalculator.process_subscription(subscription)
361
- end
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.3.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-01-01 00:00:00.000000000 Z
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