profitable 0.2.2 → 0.3.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/Appraisals +31 -0
- data/CHANGELOG.md +7 -0
- data/README.md +50 -1
- data/Rakefile +10 -1
- data/gemfiles/pay_10.0.gemfile +22 -0
- data/gemfiles/pay_11.0.gemfile +22 -0
- data/gemfiles/pay_7.3.gemfile +22 -0
- data/gemfiles/pay_8.3.gemfile +22 -0
- data/gemfiles/pay_9.0.gemfile +22 -0
- data/lib/profitable/json_helpers.rb +68 -0
- data/lib/profitable/mrr_calculator.rb +12 -2
- data/lib/profitable/processors/base.rb +28 -5
- data/lib/profitable/processors/braintree_processor.rb +8 -3
- data/lib/profitable/processors/paddle_billing_processor.rb +22 -7
- data/lib/profitable/processors/paddle_classic_processor.rb +7 -2
- data/lib/profitable/processors/stripe_processor.rb +24 -8
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +79 -33
- metadata +11 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f5435ab1dfe3d15abb2f92e6720a9ced3f3fb60474eaf85d407b85f8306632d
|
|
4
|
+
data.tar.gz: adc0a485926ff124fcd19bbf8c3d48eed10ac850fa459fcc9f84d78a40cddc9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e8d5f63b3d11b0b146b9225da6f63b85353efff1f944cc59a1ad78107e75aab4daacbbed5c46f7896edbc401790fbf9001d9424ca0fdbbe70afb4c3b2d587a2
|
|
7
|
+
data.tar.gz: f429f34975e2c7c48e0c16579ec675059c13d82e9c1634ebdc3e29d3a327be9ceec9662957da5a2214bc1a642aa9a23fb33be4919100b553bd3ad4112583a8c9
|
data/Appraisals
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Test against Pay 7.x (original minimum supported version)
|
|
4
|
+
appraise "pay-7.3" do
|
|
5
|
+
gem "pay", "~> 7.3.0"
|
|
6
|
+
gem "stripe", "~> 12.0"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Test against Pay 8.x
|
|
10
|
+
appraise "pay-8.3" do
|
|
11
|
+
gem "pay", "~> 8.3.0"
|
|
12
|
+
gem "stripe", "~> 13.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Test against Pay 9.x
|
|
16
|
+
appraise "pay-9.0" do
|
|
17
|
+
gem "pay", "~> 9.0.0"
|
|
18
|
+
gem "stripe", "~> 13.0"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Test against Pay 10.x (newly supported version with object column)
|
|
22
|
+
appraise "pay-10.0" do
|
|
23
|
+
gem "pay", "~> 10.0.0"
|
|
24
|
+
gem "stripe", "~> 15.0"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Test against Pay 11.x (latest version as of 2025)
|
|
28
|
+
appraise "pay-11.0" do
|
|
29
|
+
gem "pay", "~> 11.0"
|
|
30
|
+
gem "stripe", "~> 18.0"
|
|
31
|
+
end
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# `profitable`
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-01-01
|
|
4
|
+
- Add Pay v10+ support, comprehensive Minitest test suite, and 16 critical bugfixes re: wrong calculations
|
|
5
|
+
|
|
6
|
+
## [0.2.3] - 2024-09-01
|
|
7
|
+
|
|
8
|
+
- Fix the `time_to_next_mrr_milestone` estimation and make it accurate to the day
|
|
9
|
+
|
|
3
10
|
## [0.2.2] - 2024-09-01
|
|
4
11
|
|
|
5
12
|
- Improve MRR calculations with prorated churned and new MRR (hopefully fixes bad churned MRR calculations)
|
data/README.md
CHANGED
|
@@ -133,7 +133,56 @@ Profitable.mrr # => 123456
|
|
|
133
133
|
|
|
134
134
|
## Development
|
|
135
135
|
|
|
136
|
-
After checking out the repo,
|
|
136
|
+
After checking out the repo, install dependencies:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bundle install
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Running Tests
|
|
143
|
+
|
|
144
|
+
The gem includes a Minitest test suite. Run it with:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# Run all tests
|
|
148
|
+
bundle exec rake test
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Testing Against Multiple Pay Gem Versions
|
|
152
|
+
|
|
153
|
+
This gem uses [Appraisal](https://github.com/thoughtbot/appraisal) to test against multiple versions of the Pay gem, ensuring compatibility across Pay 7.x through 11.x.
|
|
154
|
+
|
|
155
|
+
**Generate appraisal gemfiles:**
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
bundle exec appraisal install
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Run tests against a specific Pay version:**
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Test against Pay 10.x
|
|
165
|
+
bundle exec appraisal pay-10.0 rake test
|
|
166
|
+
|
|
167
|
+
# Test against Pay 11.x
|
|
168
|
+
bundle exec appraisal pay-11.0 rake test
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Run tests against all Pay versions:**
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bundle exec appraisal rake test
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Database Compatibility
|
|
178
|
+
|
|
179
|
+
Tests run on SQLite by default, but the gem supports:
|
|
180
|
+
- PostgreSQL (9.3+)
|
|
181
|
+
- MySQL (5.7.9+)
|
|
182
|
+
- MariaDB (10.2.7+)
|
|
183
|
+
- SQLite (3.9.0+)
|
|
184
|
+
|
|
185
|
+
The gem automatically detects your database adapter and uses the appropriate JSON query syntax.
|
|
137
186
|
|
|
138
187
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
139
188
|
|
data/Rakefile
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
-
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
t.warning = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
task default: :test
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "pay", "~> 10.0.0"
|
|
7
|
+
gem "stripe", "~> 15.0"
|
|
8
|
+
|
|
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"
|
|
16
|
+
gem "mocha", "~> 2.1"
|
|
17
|
+
gem "activerecord", ">= 7.0"
|
|
18
|
+
gem "actionview", ">= 7.0"
|
|
19
|
+
gem "sqlite3"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "pay", "~> 11.0"
|
|
7
|
+
gem "stripe", "~> 18.0"
|
|
8
|
+
|
|
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"
|
|
16
|
+
gem "mocha", "~> 2.1"
|
|
17
|
+
gem "activerecord", ">= 7.0"
|
|
18
|
+
gem "actionview", ">= 7.0"
|
|
19
|
+
gem "sqlite3"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "pay", "~> 7.3.0"
|
|
7
|
+
gem "stripe", "~> 12.0"
|
|
8
|
+
|
|
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"
|
|
16
|
+
gem "mocha", "~> 2.1"
|
|
17
|
+
gem "activerecord", ">= 7.0"
|
|
18
|
+
gem "actionview", ">= 7.0"
|
|
19
|
+
gem "sqlite3"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "pay", "~> 8.3.0"
|
|
7
|
+
gem "stripe", "~> 13.0"
|
|
8
|
+
|
|
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"
|
|
16
|
+
gem "mocha", "~> 2.1"
|
|
17
|
+
gem "activerecord", ">= 7.0"
|
|
18
|
+
gem "actionview", ">= 7.0"
|
|
19
|
+
gem "sqlite3"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "pay", "~> 9.0.0"
|
|
7
|
+
gem "stripe", "~> 13.0"
|
|
8
|
+
|
|
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"
|
|
16
|
+
gem "mocha", "~> 2.1"
|
|
17
|
+
gem "activerecord", ">= 7.0"
|
|
18
|
+
gem "actionview", ">= 7.0"
|
|
19
|
+
gem "sqlite3"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profitable
|
|
4
|
+
module JsonHelpers
|
|
5
|
+
# Regex patterns for validating SQL identifiers to prevent SQL injection
|
|
6
|
+
# Only allows: alphanumeric characters, underscores, and dots (for table.column format)
|
|
7
|
+
VALID_TABLE_COLUMN_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
|
|
8
|
+
VALID_JSON_KEY_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
9
|
+
|
|
10
|
+
# Returns the appropriate JSON extraction syntax for the current database adapter
|
|
11
|
+
# Supports PostgreSQL, MySQL (5.7.9+), and SQLite
|
|
12
|
+
#
|
|
13
|
+
# @param table_column [String] The table and column name (e.g., 'pay_charges.object')
|
|
14
|
+
# @param json_key [String] The JSON key to extract (e.g., 'paid', 'status')
|
|
15
|
+
# @return [String] Database-specific SQL for JSON extraction
|
|
16
|
+
# @raise [ArgumentError] if table_column or json_key contain invalid characters
|
|
17
|
+
#
|
|
18
|
+
# @example PostgreSQL
|
|
19
|
+
# json_extract('pay_charges.object', 'paid')
|
|
20
|
+
# # => "pay_charges.object ->> 'paid'"
|
|
21
|
+
#
|
|
22
|
+
# @example MySQL
|
|
23
|
+
# json_extract('pay_charges.object', 'paid')
|
|
24
|
+
# # => "JSON_UNQUOTE(JSON_EXTRACT(pay_charges.object, '$.paid'))"
|
|
25
|
+
#
|
|
26
|
+
# @example SQLite
|
|
27
|
+
# json_extract('pay_charges.object', 'paid')
|
|
28
|
+
# # => "json_extract(pay_charges.object, '$.paid')"
|
|
29
|
+
def json_extract(table_column, json_key)
|
|
30
|
+
# Validate inputs to prevent SQL injection
|
|
31
|
+
validate_table_column!(table_column)
|
|
32
|
+
validate_json_key!(json_key)
|
|
33
|
+
|
|
34
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
35
|
+
|
|
36
|
+
case adapter
|
|
37
|
+
when /postgres/
|
|
38
|
+
"#{table_column} ->> '#{json_key}'"
|
|
39
|
+
when /mysql/, /trilogy/
|
|
40
|
+
# MySQL 5.7.9+ supports JSON_EXTRACT and ->> operator
|
|
41
|
+
# We use JSON_UNQUOTE(JSON_EXTRACT()) for maximum compatibility
|
|
42
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{table_column}, '$.#{json_key}'))"
|
|
43
|
+
when /sqlite/
|
|
44
|
+
"json_extract(#{table_column}, '$.#{json_key}')"
|
|
45
|
+
else
|
|
46
|
+
# Fallback to PostgreSQL syntax for unknown adapters
|
|
47
|
+
Rails.logger.warn("Unknown database adapter '#{adapter}' for JSON extraction. Falling back to PostgreSQL syntax.")
|
|
48
|
+
"#{table_column} ->> '#{json_key}'"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_table_column!(table_column)
|
|
55
|
+
unless table_column.is_a?(String) && table_column.match?(VALID_TABLE_COLUMN_PATTERN)
|
|
56
|
+
raise ArgumentError, "Invalid table_column format: #{table_column.inspect}. " \
|
|
57
|
+
"Must be alphanumeric with underscores/dots only (e.g., 'pay_charges.object')."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_json_key!(json_key)
|
|
62
|
+
unless json_key.is_a?(String) && json_key.match?(VALID_JSON_KEY_PATTERN)
|
|
63
|
+
raise ArgumentError, "Invalid json_key format: #{json_key.inspect}. " \
|
|
64
|
+
"Must be alphanumeric with underscores only (e.g., 'paid', 'status')."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -27,9 +27,13 @@ module Profitable
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def self.process_subscription(subscription)
|
|
30
|
-
return 0 if subscription.nil?
|
|
30
|
+
return 0 if subscription.nil?
|
|
31
|
+
return 0 if subscription_data(subscription).nil?
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
# Get processor from virtual attribute (set by .select() in queries) or from customer association
|
|
34
|
+
processor_name = subscription.try(:customer_processor) || subscription.customer&.processor
|
|
35
|
+
|
|
36
|
+
processor_class = processor_for(processor_name)
|
|
33
37
|
mrr = processor_class.new(subscription).calculate_mrr
|
|
34
38
|
|
|
35
39
|
# Ensure MRR is a non-negative number
|
|
@@ -39,6 +43,12 @@ module Profitable
|
|
|
39
43
|
0
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# Pay gem v10+ stores Stripe objects in the `object` column,
|
|
47
|
+
# while older versions used `data`. This method provides backwards compatibility.
|
|
48
|
+
def self.subscription_data(subscription)
|
|
49
|
+
subscription.try(:object) || subscription.try(:data)
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
def self.processor_for(processor_name)
|
|
43
53
|
case processor_name
|
|
44
54
|
when 'stripe'
|
|
@@ -13,22 +13,45 @@ module Profitable
|
|
|
13
13
|
|
|
14
14
|
protected
|
|
15
15
|
|
|
16
|
+
# Pay gem v10+ stores Stripe objects in the `object` column,
|
|
17
|
+
# while older versions used `data`. This method provides backwards compatibility.
|
|
18
|
+
def subscription_data
|
|
19
|
+
subscription.try(:object) || subscription.try(:data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Converts a billing amount to its monthly equivalent rate.
|
|
23
|
+
#
|
|
24
|
+
# Uses floating-point arithmetic for precision during calculation,
|
|
25
|
+
# then rounds to the nearest integer cent at the end. This approach
|
|
26
|
+
# ensures accurate rounding for fractional results (e.g., $100/year = $8.33/month = 833 cents).
|
|
27
|
+
#
|
|
28
|
+
# @param amount [Integer, String] The billing amount in cents
|
|
29
|
+
# @param interval [String] The billing interval ('day', 'week', 'month', 'year')
|
|
30
|
+
# @param interval_count [Integer, String] How many intervals per billing cycle
|
|
31
|
+
# @return [Integer] The monthly amount in cents (always a non-negative integer)
|
|
16
32
|
def normalize_to_monthly(amount, interval, interval_count)
|
|
17
33
|
return 0 if amount.nil? || interval.nil? || interval_count.nil?
|
|
18
34
|
|
|
19
|
-
|
|
35
|
+
# Ensure interval_count is converted to integer before division
|
|
36
|
+
interval_count_int = interval_count.to_i
|
|
37
|
+
return 0 if interval_count_int.zero?
|
|
38
|
+
|
|
39
|
+
# Calculate using floats for precision, round at the end for integer cents
|
|
40
|
+
monthly_amount = case interval.to_s.downcase
|
|
20
41
|
when 'day'
|
|
21
|
-
amount * 30
|
|
42
|
+
amount.to_f * 30 / interval_count_int
|
|
22
43
|
when 'week'
|
|
23
|
-
amount * 4
|
|
44
|
+
amount.to_f * 4 / interval_count_int
|
|
24
45
|
when 'month'
|
|
25
|
-
amount /
|
|
46
|
+
amount.to_f / interval_count_int
|
|
26
47
|
when 'year'
|
|
27
|
-
amount / (12
|
|
48
|
+
amount.to_f / (12 * interval_count_int)
|
|
28
49
|
else
|
|
29
50
|
Rails.logger.warn("Unknown interval for MRR calculation: #{interval}")
|
|
30
51
|
0
|
|
31
52
|
end
|
|
53
|
+
|
|
54
|
+
monthly_amount.round # Return integer cents
|
|
32
55
|
end
|
|
33
56
|
end
|
|
34
57
|
end
|
|
@@ -2,10 +2,15 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class BraintreeProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
|
+
|
|
8
|
+
amount = data['price']
|
|
9
|
+
return 0 if amount.nil?
|
|
10
|
+
|
|
6
11
|
quantity = subscription.quantity || 1
|
|
7
|
-
interval =
|
|
8
|
-
interval_count =
|
|
12
|
+
interval = data['billing_period_unit']
|
|
13
|
+
interval_count = data['billing_period_frequency'] || 1
|
|
9
14
|
|
|
10
15
|
normalize_to_monthly(amount * quantity, interval, interval_count)
|
|
11
16
|
end
|
|
@@ -2,15 +2,30 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class PaddleBillingProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
6
|
-
return 0 if
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
interval = price_data['billing_cycle']['interval']
|
|
11
|
-
interval_count = price_data['billing_cycle']['frequency']
|
|
8
|
+
items = data['items']
|
|
9
|
+
return 0 if items.nil? || items.empty?
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
# Sum MRR from ALL subscription items
|
|
12
|
+
total_mrr = 0
|
|
13
|
+
|
|
14
|
+
items.each do |item|
|
|
15
|
+
price_data = item['price']
|
|
16
|
+
next if price_data.nil?
|
|
17
|
+
|
|
18
|
+
amount = price_data.dig('unit_price', 'amount')
|
|
19
|
+
next if amount.nil?
|
|
20
|
+
|
|
21
|
+
item_quantity = item['quantity'] || 1
|
|
22
|
+
interval = price_data.dig('billing_cycle', 'interval')
|
|
23
|
+
interval_count = price_data.dig('billing_cycle', 'frequency')
|
|
24
|
+
|
|
25
|
+
total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
total_mrr
|
|
14
29
|
end
|
|
15
30
|
end
|
|
16
31
|
end
|
|
@@ -2,9 +2,14 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class PaddleClassicProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
|
+
|
|
8
|
+
amount = data['recurring_price']
|
|
9
|
+
return 0 if amount.nil?
|
|
10
|
+
|
|
6
11
|
quantity = subscription.quantity || 1
|
|
7
|
-
interval =
|
|
12
|
+
interval = data['recurring_interval']
|
|
8
13
|
interval_count = 1 # Paddle Classic doesn't have interval_count
|
|
9
14
|
|
|
10
15
|
normalize_to_monthly(amount * quantity, interval, interval_count)
|
|
@@ -2,18 +2,34 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class StripeProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
|
+
|
|
8
|
+
# Pay gem v10+ stores items at object['items']['data']
|
|
9
|
+
# Older versions stored at data['subscription_items']
|
|
10
|
+
subscription_items = data.dig('items', 'data') || data['subscription_items']
|
|
6
11
|
return 0 if subscription_items.nil? || subscription_items.empty?
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
# Sum MRR from ALL subscription items (not just the first one)
|
|
14
|
+
# Stripe subscriptions can have multiple line items
|
|
15
|
+
total_mrr = 0
|
|
16
|
+
|
|
17
|
+
subscription_items.each do |item|
|
|
18
|
+
price_data = item['price'] || item
|
|
19
|
+
next if price_data.nil?
|
|
20
|
+
|
|
21
|
+
amount = price_data['unit_amount']
|
|
22
|
+
next if amount.nil?
|
|
23
|
+
|
|
24
|
+
# Each item can have its own quantity
|
|
25
|
+
item_quantity = item['quantity'] || 1
|
|
26
|
+
interval = price_data.dig('recurring', 'interval')
|
|
27
|
+
interval_count = price_data.dig('recurring', 'interval_count') || 1
|
|
10
28
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
interval = price_data.dig('recurring', 'interval')
|
|
14
|
-
interval_count = price_data.dig('recurring', 'interval_count') || 1
|
|
29
|
+
total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
|
|
30
|
+
end
|
|
15
31
|
|
|
16
|
-
|
|
32
|
+
total_mrr
|
|
17
33
|
end
|
|
18
34
|
end
|
|
19
35
|
end
|
data/lib/profitable/version.rb
CHANGED
data/lib/profitable.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "profitable/engine"
|
|
|
6
6
|
|
|
7
7
|
require_relative "profitable/mrr_calculator"
|
|
8
8
|
require_relative "profitable/numeric_result"
|
|
9
|
+
require_relative "profitable/json_helpers"
|
|
9
10
|
|
|
10
11
|
require "pay"
|
|
11
12
|
require "active_support/core_ext/numeric/conversions"
|
|
@@ -14,6 +15,7 @@ require "action_view"
|
|
|
14
15
|
module Profitable
|
|
15
16
|
class << self
|
|
16
17
|
include ActionView::Helpers::NumberHelper
|
|
18
|
+
include Profitable::JsonHelpers
|
|
17
19
|
|
|
18
20
|
DEFAULT_PERIOD = 30.days
|
|
19
21
|
MRR_MILESTONES = [5, 10, 20, 30, 50, 75, 100, 200, 300, 400, 500, 1_000, 2_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000, 83_333, 100_000, 250_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000]
|
|
@@ -100,24 +102,57 @@ module Profitable
|
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
def time_to_next_mrr_milestone
|
|
103
|
-
current_mrr = (mrr.to_i)/100
|
|
105
|
+
current_mrr = (mrr.to_i) / 100 # Convert cents to dollars
|
|
106
|
+
return "Unable to calculate. No MRR yet." if current_mrr <= 0
|
|
107
|
+
|
|
104
108
|
next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr }
|
|
105
109
|
return "Congratulations! You've reached the highest milestone." unless next_milestone
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
return "Unable to calculate. Need more data or positive growth." if
|
|
111
|
+
monthly_growth_rate = calculate_mrr_growth_rate / 100
|
|
112
|
+
return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0
|
|
113
|
+
|
|
114
|
+
# Convert monthly growth rate to daily growth rate
|
|
115
|
+
daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1
|
|
116
|
+
return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0
|
|
117
|
+
|
|
118
|
+
# Calculate the number of days to reach the next milestone
|
|
119
|
+
days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
days_to_milestone = months_to_milestone * 30
|
|
121
|
+
target_date = Time.current + days_to_milestone.days
|
|
112
122
|
|
|
113
|
-
|
|
123
|
+
"#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})"
|
|
114
124
|
end
|
|
115
125
|
|
|
116
126
|
private
|
|
117
127
|
|
|
118
128
|
def paid_charges
|
|
119
|
-
Pay
|
|
120
|
-
|
|
129
|
+
# Pay gem v10+ stores charge data in `object` column, older versions used `data`
|
|
130
|
+
# We check both columns for backwards compatibility using database-agnostic JSON extraction
|
|
131
|
+
#
|
|
132
|
+
# Performance note: The COALESCE pattern may prevent index usage on some databases.
|
|
133
|
+
# This is an acceptable tradeoff for backwards compatibility with Pay < 10.
|
|
134
|
+
# For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+
|
|
135
|
+
# where only the `object` column is used.
|
|
136
|
+
|
|
137
|
+
# Build JSON extraction SQL for both object and data columns
|
|
138
|
+
paid_object = json_extract('pay_charges.object', 'paid')
|
|
139
|
+
paid_data = json_extract('pay_charges.data', 'paid')
|
|
140
|
+
status_object = json_extract('pay_charges.object', 'status')
|
|
141
|
+
status_data = json_extract('pay_charges.data', 'status')
|
|
142
|
+
|
|
143
|
+
Pay::Charge
|
|
144
|
+
.where("pay_charges.amount > 0")
|
|
145
|
+
.where(<<~SQL.squish, 'false', 'succeeded')
|
|
146
|
+
(
|
|
147
|
+
(COALESCE(#{paid_object}, #{paid_data}) IS NULL
|
|
148
|
+
OR COALESCE(#{paid_object}, #{paid_data}) != ?)
|
|
149
|
+
)
|
|
150
|
+
AND
|
|
151
|
+
(
|
|
152
|
+
COALESCE(#{status_object}, #{status_data}) = ?
|
|
153
|
+
OR COALESCE(#{status_object}, #{status_data}) IS NULL
|
|
154
|
+
)
|
|
155
|
+
SQL
|
|
121
156
|
end
|
|
122
157
|
|
|
123
158
|
def calculate_all_time_revenue
|
|
@@ -150,7 +185,16 @@ module Profitable
|
|
|
150
185
|
|
|
151
186
|
def calculate_churn(period = DEFAULT_PERIOD)
|
|
152
187
|
start_date = period.ago
|
|
153
|
-
|
|
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
|
+
|
|
154
198
|
churned = calculate_churned_customers(period)
|
|
155
199
|
return 0 if total_subscribers_start == 0
|
|
156
200
|
(churned.to_f / total_subscribers_start * 100).round(2)
|
|
@@ -173,26 +217,16 @@ module Profitable
|
|
|
173
217
|
start_date = period.ago
|
|
174
218
|
end_date = Time.current
|
|
175
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
|
|
176
222
|
Pay::Subscription
|
|
177
223
|
.includes(:customer)
|
|
178
224
|
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
|
179
225
|
.joins(:customer)
|
|
180
226
|
.where(status: ['canceled', 'ended'])
|
|
181
|
-
.where(
|
|
227
|
+
.where(ends_at: start_date..end_date)
|
|
182
228
|
.sum do |subscription|
|
|
183
|
-
|
|
184
|
-
# Subscription ends in the future, don't count it as churned yet
|
|
185
|
-
0
|
|
186
|
-
else
|
|
187
|
-
# Calculate prorated MRR if the subscription ended within the period
|
|
188
|
-
end_date = [subscription.ends_at, end_date].compact.min
|
|
189
|
-
days_in_period = (end_date - start_date).to_i
|
|
190
|
-
total_days = (subscription.current_period_end - subscription.current_period_start).to_i
|
|
191
|
-
prorated_days = [days_in_period, total_days].min
|
|
192
|
-
|
|
193
|
-
mrr = MrrCalculator.process_subscription(subscription)
|
|
194
|
-
(mrr.to_f * prorated_days / total_days).round
|
|
195
|
-
end
|
|
229
|
+
MrrCalculator.process_subscription(subscription)
|
|
196
230
|
end
|
|
197
231
|
end
|
|
198
232
|
|
|
@@ -200,6 +234,8 @@ module Profitable
|
|
|
200
234
|
start_date = period.ago
|
|
201
235
|
end_date = Time.current
|
|
202
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
|
|
203
239
|
Pay::Subscription
|
|
204
240
|
.active
|
|
205
241
|
.includes(:customer)
|
|
@@ -208,11 +244,7 @@ module Profitable
|
|
|
208
244
|
.where(created_at: start_date..end_date)
|
|
209
245
|
.where.not(status: ['trialing', 'paused'])
|
|
210
246
|
.sum do |subscription|
|
|
211
|
-
|
|
212
|
-
days_in_period = (end_date - subscription.created_at).to_i
|
|
213
|
-
total_days = (subscription.current_period_end - subscription.current_period_start).to_i
|
|
214
|
-
prorated_days = [days_in_period, total_days].min
|
|
215
|
-
(mrr.to_f * prorated_days / total_days).round
|
|
247
|
+
MrrCalculator.process_subscription(subscription)
|
|
216
248
|
end
|
|
217
249
|
end
|
|
218
250
|
|
|
@@ -266,8 +298,10 @@ module Profitable
|
|
|
266
298
|
end
|
|
267
299
|
|
|
268
300
|
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)
|
|
269
303
|
Pay::Customer.joins(:subscriptions)
|
|
270
|
-
.where(created_at: period.ago..Time.current)
|
|
304
|
+
.where(pay_subscriptions: { created_at: period.ago..Time.current })
|
|
271
305
|
.distinct
|
|
272
306
|
.count
|
|
273
307
|
end
|
|
@@ -279,10 +313,16 @@ module Profitable
|
|
|
279
313
|
end
|
|
280
314
|
|
|
281
315
|
def calculate_lifetime_value
|
|
282
|
-
|
|
283
|
-
|
|
316
|
+
# LTV = Monthly ARPU / Monthly Churn Rate
|
|
317
|
+
# where ARPU (Average Revenue Per User) = MRR / active subscribers
|
|
318
|
+
subscribers = calculate_active_subscribers
|
|
319
|
+
return 0 if subscribers.zero?
|
|
320
|
+
|
|
321
|
+
monthly_arpu = mrr.to_f / subscribers # in cents
|
|
322
|
+
churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05)
|
|
284
323
|
return 0 if churn_rate.zero?
|
|
285
|
-
|
|
324
|
+
|
|
325
|
+
(monthly_arpu / churn_rate).round # LTV in cents
|
|
286
326
|
end
|
|
287
327
|
|
|
288
328
|
def calculate_mrr_growth(period = DEFAULT_PERIOD)
|
|
@@ -303,9 +343,15 @@ module Profitable
|
|
|
303
343
|
end
|
|
304
344
|
|
|
305
345
|
def calculate_mrr_at(date)
|
|
346
|
+
# Find subscriptions that were active AT the given date:
|
|
347
|
+
# - Created before or on that date
|
|
348
|
+
# - Not ended before that date (ends_at is nil OR ends_at > date)
|
|
349
|
+
# - Not paused at that date
|
|
350
|
+
# - Not in trialing status (trials don't count as MRR)
|
|
306
351
|
Pay::Subscription
|
|
307
|
-
.active
|
|
308
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)
|
|
309
355
|
.where.not(status: ['trialing', 'paused'])
|
|
310
356
|
.includes(:customer)
|
|
311
357
|
.select('pay_subscriptions.*, pay_customers.processor as customer_processor')
|
metadata
CHANGED
|
@@ -1,14 +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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 2026-01-01 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: pay
|
|
@@ -47,6 +46,7 @@ executables: []
|
|
|
47
46
|
extensions: []
|
|
48
47
|
extra_rdoc_files: []
|
|
49
48
|
files:
|
|
49
|
+
- Appraisals
|
|
50
50
|
- CHANGELOG.md
|
|
51
51
|
- LICENSE.txt
|
|
52
52
|
- README.md
|
|
@@ -56,9 +56,15 @@ files:
|
|
|
56
56
|
- app/views/layouts/profitable/application.html.erb
|
|
57
57
|
- app/views/profitable/dashboard/index.html.erb
|
|
58
58
|
- config/routes.rb
|
|
59
|
+
- gemfiles/pay_10.0.gemfile
|
|
60
|
+
- gemfiles/pay_11.0.gemfile
|
|
61
|
+
- gemfiles/pay_7.3.gemfile
|
|
62
|
+
- gemfiles/pay_8.3.gemfile
|
|
63
|
+
- gemfiles/pay_9.0.gemfile
|
|
59
64
|
- lib/profitable.rb
|
|
60
65
|
- lib/profitable/engine.rb
|
|
61
66
|
- lib/profitable/error.rb
|
|
67
|
+
- lib/profitable/json_helpers.rb
|
|
62
68
|
- lib/profitable/mrr_calculator.rb
|
|
63
69
|
- lib/profitable/numeric_result.rb
|
|
64
70
|
- lib/profitable/processors/base.rb
|
|
@@ -76,8 +82,7 @@ metadata:
|
|
|
76
82
|
allowed_push_host: https://rubygems.org
|
|
77
83
|
homepage_uri: https://github.com/rameerez/profitable
|
|
78
84
|
source_code_uri: https://github.com/rameerez/profitable
|
|
79
|
-
changelog_uri: https://github.com/rameerez/profitable
|
|
80
|
-
post_install_message:
|
|
85
|
+
changelog_uri: https://github.com/rameerez/profitable/blob/main/CHANGELOG.md
|
|
81
86
|
rdoc_options: []
|
|
82
87
|
require_paths:
|
|
83
88
|
- lib
|
|
@@ -92,8 +97,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
92
97
|
- !ruby/object:Gem::Version
|
|
93
98
|
version: '0'
|
|
94
99
|
requirements: []
|
|
95
|
-
rubygems_version: 3.
|
|
96
|
-
signing_key:
|
|
100
|
+
rubygems_version: 3.6.2
|
|
97
101
|
specification_version: 4
|
|
98
102
|
summary: Calculate the MRR, ARR, churn, LTV, ARPU, total revenue & est. valuation
|
|
99
103
|
of your `pay`-powered Rails SaaS
|