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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3487fdc62384e3d5900e96c7642b10d44a2d701406677702b8608585f1fd3a5
4
- data.tar.gz: e455302bf61fc7aa7aa0f0f369406bb3fd24ebc3e9b13e56516eb89fb5508990
3
+ metadata.gz: 6f5435ab1dfe3d15abb2f92e6720a9ced3f3fb60474eaf85d407b85f8306632d
4
+ data.tar.gz: adc0a485926ff124fcd19bbf8c3d48eed10ac850fa459fcc9f84d78a40cddc9f
5
5
  SHA512:
6
- metadata.gz: b0b107c91d16eabd6867b6bb4eafc0392272a41e6380f4c1f02be690f97f994fa4cb6a17d900ab930316ec0e682b391b6d36807d041387680e81e92a8150d774
7
- data.tar.gz: 3116c70c4ba0257c59939da5ac83a10a841df147eaba9d8a1980bb7a2e7b1103ab4cbc15317461809ad3927cf5ffd612151a7538e3cfa34bdb47364c0f96bf60
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, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
- task default: %i[]
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? || subscription.data.nil?
30
+ return 0 if subscription.nil?
31
+ return 0 if subscription_data(subscription).nil?
31
32
 
32
- processor_class = processor_for(subscription.customer_processor)
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
- case interval.to_s.downcase
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.0 / interval_count
42
+ amount.to_f * 30 / interval_count_int
22
43
  when 'week'
23
- amount * 4.0 / interval_count
44
+ amount.to_f * 4 / interval_count_int
24
45
  when 'month'
25
- amount / interval_count
46
+ amount.to_f / interval_count_int
26
47
  when 'year'
27
- amount / (12.0 * interval_count)
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
- amount = subscription.data['price']
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 = subscription.data['billing_period_unit']
8
- interval_count = subscription.data['billing_period_frequency'] || 1
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
- price_data = subscription.data['items']&.first&.dig('price')
6
- return 0 if price_data.nil?
5
+ data = subscription_data
6
+ return 0 if data.nil?
7
7
 
8
- amount = price_data['unit_price']['amount']
9
- quantity = subscription.quantity || 1
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
- normalize_to_monthly(amount * quantity, interval, interval_count)
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
- amount = subscription.data['recurring_price']
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 = subscription.data['recurring_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
- subscription_items = subscription.data['subscription_items']
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
- price_data = subscription_items[0]['price']
9
- return 0 if price_data.nil?
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
- amount = price_data['unit_amount']
12
- quantity = subscription.quantity || 1
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
- normalize_to_monthly(amount * quantity, interval, interval_count)
32
+ total_mrr
17
33
  end
18
34
  end
19
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profitable
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
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
- growth_rate = calculate_mrr_growth_rate
108
- return "Unable to calculate. Need more data or positive growth." if growth_rate <= 0
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
- months_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + growth_rate)).ceil
111
- days_to_milestone = months_to_milestone * 30
121
+ target_date = Time.current + days_to_milestone.days
112
122
 
113
- return "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{(Time.current + days_to_milestone.days).strftime('%b %d, %Y')})"
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::Charge.where("(pay_charges.data ->> 'paid' IS NULL OR pay_charges.data ->> 'paid' != ?) AND pay_charges.amount > 0", 'false')
120
- .where("pay_charges.data ->> 'status' = ? OR pay_charges.data ->> 'status' IS NULL", 'succeeded')
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
- total_subscribers_start = Pay::Subscription.active.where('created_at < ?', start_date).distinct.count('customer_id')
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('pay_subscriptions.updated_at BETWEEN ? AND ?', start_date, end_date)
227
+ .where(ends_at: start_date..end_date)
182
228
  .sum do |subscription|
183
- if subscription.ends_at && subscription.ends_at > end_date
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
- mrr = MrrCalculator.process_subscription(subscription)
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
- return 0 if total_customers.zero?
283
- churn_rate = churn.to_f / 100
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
- (average_revenue_per_customer.to_f / churn_rate).round
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.2.2
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: 2024-09-01 00:00:00.000000000 Z
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.5.17
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