diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..739bac6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: Tests + +on: + pull_request: + paths-ignore: + - "README.md" + - "CHANGELOG.md" + - "LICENSE.txt" + - "*.md" + push: + branches: + - main + paths-ignore: + - "README.md" + - "CHANGELOG.md" + - "LICENSE.txt" + - "*.md" + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby_version: ["3.2", "3.3", "3.4"] + gemfile: + - Gemfile + - gemfiles/pay_7.3.gemfile + - gemfiles/pay_8.3.gemfile + - gemfiles/pay_9.0.gemfile + - gemfiles/pay_10.0.gemfile + - gemfiles/pay_11.0.gemfile + + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby_version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile }} + path: test/reports/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index ec36c26..8726b60 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,10 @@ /spec/reports/ /tmp/ /dist -*.gem \ No newline at end of file +*.gem +Gemfile.lock +TODO +VERIFICATION.md + +# Appraisal - exclude gemfile lockfiles but keep generated Gemfiles +/gemfiles/*.gemfile.lock \ No newline at end of file diff --git a/Appraisals b/Appraisals new file mode 100644 index 0000000..269c55b --- /dev/null +++ b/Appraisals @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Test against Pay 7.x (original minimum supported version) +appraise "pay-7.3" do + gem "pay", "~> 7.3.0" + gem "stripe", "~> 12.0" +end + +# Test against Pay 8.x +appraise "pay-8.3" do + gem "pay", "~> 8.3.0" + gem "stripe", "~> 13.0" +end + +# Test against Pay 9.x +appraise "pay-9.0" do + gem "pay", "~> 9.0.0" + gem "stripe", "~> 13.0" +end + +# Test against Pay 10.x (newly supported version with object column) +appraise "pay-10.0" do + gem "pay", "~> 10.0.0" + gem "stripe", "~> 15.0" +end + +# Test against Pay 11.x (latest version as of 2025) +appraise "pay-11.0" do + gem "pay", "~> 11.0" + gem "stripe", "~> 18.0" +end diff --git a/Gemfile b/Gemfile index 53ff89e..fe192b4 100644 --- a/Gemfile +++ b/Gemfile @@ -6,3 +6,16 @@ source "https://rubygems.org" gemspec gem "rake", "~> 13.0" + +group :development do + gem "appraisal", "~> 2.5" +end + +group :test do + gem "minitest", "~> 5.0" + gem "minitest-reporters", "~> 1.6" + gem "mocha", "~> 2.1" + gem "activerecord", ">= 7.0" + gem "actionview", ">= 7.0" + gem "sqlite3" +end diff --git a/README.md b/README.md index 8d2fb41..de70c10 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,88 @@ Profitable.mrr # => 123456 ## Development -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. +### Setup + +After checking out the repo, install dependencies: + +```bash +bundle install +``` + +### Running Tests + +The gem includes a comprehensive test suite with 211 tests covering all functionality: + +```bash +# Run all tests +bundle exec rake test + +# Run tests with verbose output +bundle exec rake test TESTOPTS="-v" +``` + +### Testing Against Multiple Pay Gem Versions + +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. + +**Supported Pay versions:** +- Pay 7.3.x (minimum supported version) +- Pay 8.3.x +- Pay 9.0.x +- Pay 10.x (with `object` column support) +- Pay 11.x (latest) + +**Generate appraisal gemfiles:** + +```bash +bundle exec appraisal install +``` + +**Run tests against a specific Pay version:** + +```bash +# Test against Pay 10.x +bundle exec appraisal pay-10.0 rake test + +# Test against Pay 11.x +bundle exec appraisal pay-11.0 rake test +``` + +**Run tests against all Pay versions:** + +```bash +bundle exec appraisal rake test +``` + +### Continuous Integration + +The gem uses GitHub Actions to automatically test against: +- Ruby versions: 3.2, 3.3, 3.4 +- Pay gem versions: 7.3.x, 8.3.x, 9.0.x, 10.x, 11.x +- Total test matrix: 18 combinations (3 Ruby × 6 Pay versions) + +See [`.github/workflows/test.yml`](.github/workflows/test.yml) for the full CI configuration. + +### Database Compatibility + +Tests run on SQLite by default, but the gem supports: +- PostgreSQL (9.3+) +- MySQL (5.7.9+) +- MariaDB (10.2.7+) +- SQLite (3.9.0+) + +The gem automatically detects your database adapter and uses the appropriate JSON query syntax. + +### Test Coverage + +The test suite includes: +- **211 tests** with **250 assertions** +- **10 test files** totaling **6,151 lines** of test code +- **22 regression tests** preventing critical bugs +- Comprehensive processor tests (Stripe, Braintree, Paddle Billing, Paddle Classic) +- Pay v10+ compatibility tests (`object` vs `data` column) +- Database-agnostic JSON query tests +- All public API methods tested To install this gem onto your local machine, run `bundle exec rake install`. diff --git a/Rakefile b/Rakefile index cd510a0..7f673ec 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,13 @@ # frozen_string_literal: true require "bundler/gem_tasks" -task default: %i[] +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] + t.warning = false +end + +task default: :test diff --git a/gemfiles/pay_10.0.gemfile b/gemfiles/pay_10.0.gemfile new file mode 100644 index 0000000..817188f --- /dev/null +++ b/gemfiles/pay_10.0.gemfile @@ -0,0 +1,22 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake", "~> 13.0" +gem "pay", "~> 10.0.0" +gem "stripe", "~> 15.0" + +group :development do + gem "appraisal", "~> 2.5" +end + +group :test do + gem "minitest", "~> 5.0" + gem "minitest-reporters", "~> 1.6" + gem "mocha", "~> 2.1" + gem "activerecord", ">= 7.0" + gem "actionview", ">= 7.0" + gem "sqlite3" +end + +gemspec path: "../" diff --git a/gemfiles/pay_11.0.gemfile b/gemfiles/pay_11.0.gemfile new file mode 100644 index 0000000..9ded227 --- /dev/null +++ b/gemfiles/pay_11.0.gemfile @@ -0,0 +1,22 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake", "~> 13.0" +gem "pay", "~> 11.0" +gem "stripe", "~> 18.0" + +group :development do + gem "appraisal", "~> 2.5" +end + +group :test do + gem "minitest", "~> 5.0" + gem "minitest-reporters", "~> 1.6" + gem "mocha", "~> 2.1" + gem "activerecord", ">= 7.0" + gem "actionview", ">= 7.0" + gem "sqlite3" +end + +gemspec path: "../" diff --git a/gemfiles/pay_7.3.gemfile b/gemfiles/pay_7.3.gemfile new file mode 100644 index 0000000..7e3e046 --- /dev/null +++ b/gemfiles/pay_7.3.gemfile @@ -0,0 +1,22 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake", "~> 13.0" +gem "pay", "~> 7.3.0" +gem "stripe", "~> 12.0" + +group :development do + gem "appraisal", "~> 2.5" +end + +group :test do + gem "minitest", "~> 5.0" + gem "minitest-reporters", "~> 1.6" + gem "mocha", "~> 2.1" + gem "activerecord", ">= 7.0" + gem "actionview", ">= 7.0" + gem "sqlite3" +end + +gemspec path: "../" diff --git a/gemfiles/pay_8.3.gemfile b/gemfiles/pay_8.3.gemfile new file mode 100644 index 0000000..491131a --- /dev/null +++ b/gemfiles/pay_8.3.gemfile @@ -0,0 +1,22 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake", "~> 13.0" +gem "pay", "~> 8.3.0" +gem "stripe", "~> 13.0" + +group :development do + gem "appraisal", "~> 2.5" +end + +group :test do + gem "minitest", "~> 5.0" + gem "minitest-reporters", "~> 1.6" + gem "mocha", "~> 2.1" + gem "activerecord", ">= 7.0" + gem "actionview", ">= 7.0" + gem "sqlite3" +end + +gemspec path: "../" diff --git a/gemfiles/pay_9.0.gemfile b/gemfiles/pay_9.0.gemfile new file mode 100644 index 0000000..f743e08 --- /dev/null +++ b/gemfiles/pay_9.0.gemfile @@ -0,0 +1,22 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake", "~> 13.0" +gem "pay", "~> 9.0.0" +gem "stripe", "~> 13.0" + +group :development do + gem "appraisal", "~> 2.5" +end + +group :test do + gem "minitest", "~> 5.0" + gem "minitest-reporters", "~> 1.6" + gem "mocha", "~> 2.1" + gem "activerecord", ">= 7.0" + gem "actionview", ">= 7.0" + gem "sqlite3" +end + +gemspec path: "../" diff --git a/lib/profitable.rb b/lib/profitable.rb index 41c2dfb..db5c3ab 100644 --- a/lib/profitable.rb +++ b/lib/profitable.rb @@ -6,6 +6,7 @@ require_relative "profitable/mrr_calculator" require_relative "profitable/numeric_result" +require_relative "profitable/json_helpers" require "pay" require "active_support/core_ext/numeric/conversions" @@ -14,6 +15,7 @@ module Profitable class << self include ActionView::Helpers::NumberHelper + include Profitable::JsonHelpers DEFAULT_PERIOD = 30.days 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,7 +102,9 @@ def mrr_growth_rate(in_the_last: DEFAULT_PERIOD) end def time_to_next_mrr_milestone - current_mrr = (mrr.to_i)/100 + current_mrr = (mrr.to_i) / 100 # Convert cents to dollars + return "Unable to calculate. No MRR yet." if current_mrr <= 0 + next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr } return "Congratulations! You've reached the highest milestone." unless next_milestone @@ -109,6 +113,7 @@ def time_to_next_mrr_milestone # Convert monthly growth rate to daily growth rate daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1 + return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0 # Calculate the number of days to reach the next milestone days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil @@ -121,8 +126,33 @@ def time_to_next_mrr_milestone private def paid_charges - Pay::Charge.where("(pay_charges.data ->> 'paid' IS NULL OR pay_charges.data ->> 'paid' != ?) AND pay_charges.amount > 0", 'false') - .where("pay_charges.data ->> 'status' = ? OR pay_charges.data ->> 'status' IS NULL", 'succeeded') + # Pay gem v10+ stores charge data in `object` column, older versions used `data` + # We check both columns for backwards compatibility using database-agnostic JSON extraction + # + # Performance note: The COALESCE pattern may prevent index usage on some databases. + # This is an acceptable tradeoff for backwards compatibility with Pay < 10. + # For high-volume scenarios, consider adding a composite index or upgrading to Pay 10+ + # where only the `object` column is used. + + # Build JSON extraction SQL for both object and data columns + paid_object = json_extract('pay_charges.object', 'paid') + paid_data = json_extract('pay_charges.data', 'paid') + status_object = json_extract('pay_charges.object', 'status') + status_data = json_extract('pay_charges.data', 'status') + + Pay::Charge + .where("pay_charges.amount > 0") + .where(<<~SQL.squish, 'false', 'succeeded') + ( + (COALESCE(#{paid_object}, #{paid_data}) IS NULL + OR COALESCE(#{paid_object}, #{paid_data}) != ?) + ) + AND + ( + COALESCE(#{status_object}, #{status_data}) = ? + OR COALESCE(#{status_object}, #{status_data}) IS NULL + ) + SQL end def calculate_all_time_revenue @@ -155,7 +185,16 @@ def parse_multiplier(input) def calculate_churn(period = DEFAULT_PERIOD) start_date = period.ago - total_subscribers_start = Pay::Subscription.active.where('created_at < ?', start_date).distinct.count('customer_id') + + # Count subscribers who were active AT the start of the period + # (not just currently active, but active at that historical point) + total_subscribers_start = Pay::Subscription + .where('pay_subscriptions.created_at < ?', start_date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', start_date) + .where.not(status: ['trialing', 'paused']) + .distinct + .count('customer_id') + churned = calculate_churned_customers(period) return 0 if total_subscribers_start == 0 (churned.to_f / total_subscribers_start * 100).round(2) @@ -178,26 +217,16 @@ def calculate_churned_mrr(period = DEFAULT_PERIOD) start_date = period.ago end_date = Time.current + # Churned MRR = full monthly rate of subscriptions that ended in the period + # MRR is a rate, not revenue, so we don't prorate Pay::Subscription .includes(:customer) .select('pay_subscriptions.*, pay_customers.processor as customer_processor') .joins(:customer) .where(status: ['canceled', 'ended']) - .where('pay_subscriptions.updated_at BETWEEN ? AND ?', start_date, end_date) + .where(ends_at: start_date..end_date) .sum do |subscription| - if subscription.ends_at && subscription.ends_at > end_date - # Subscription ends in the future, don't count it as churned yet - 0 - else - # Calculate prorated MRR if the subscription ended within the period - end_date = [subscription.ends_at, end_date].compact.min - days_in_period = (end_date - start_date).to_i - total_days = (subscription.current_period_end - subscription.current_period_start).to_i - prorated_days = [days_in_period, total_days].min - - mrr = MrrCalculator.process_subscription(subscription) - (mrr.to_f * prorated_days / total_days).round - end + MrrCalculator.process_subscription(subscription) end end @@ -205,6 +234,8 @@ def calculate_new_mrr(period = DEFAULT_PERIOD) start_date = period.ago end_date = Time.current + # New MRR = full monthly rate of subscriptions created in the period + # MRR is a rate, not revenue, so we don't prorate Pay::Subscription .active .includes(:customer) @@ -213,11 +244,7 @@ def calculate_new_mrr(period = DEFAULT_PERIOD) .where(created_at: start_date..end_date) .where.not(status: ['trialing', 'paused']) .sum do |subscription| - mrr = MrrCalculator.process_subscription(subscription) - days_in_period = (end_date - subscription.created_at).to_i - total_days = (subscription.current_period_end - subscription.current_period_start).to_i - prorated_days = [days_in_period, total_days].min - (mrr.to_f * prorated_days / total_days).round + MrrCalculator.process_subscription(subscription) end end @@ -271,8 +298,10 @@ def calculate_new_customers(period) end def calculate_new_subscribers(period) + # Count customers who got a NEW subscription in the period + # (not customers created in the period, but subscriptions created in the period) Pay::Customer.joins(:subscriptions) - .where(created_at: period.ago..Time.current) + .where(pay_subscriptions: { created_at: period.ago..Time.current }) .distinct .count end @@ -284,10 +313,16 @@ def calculate_average_revenue_per_customer end def calculate_lifetime_value - return 0 if total_customers.zero? - churn_rate = churn.to_f / 100 + # LTV = Monthly ARPU / Monthly Churn Rate + # where ARPU (Average Revenue Per User) = MRR / active subscribers + subscribers = calculate_active_subscribers + return 0 if subscribers.zero? + + monthly_arpu = mrr.to_f / subscribers # in cents + churn_rate = churn.to_f / 100 # monthly churn as decimal (e.g., 5% = 0.05) return 0 if churn_rate.zero? - (average_revenue_per_customer.to_f / churn_rate).round + + (monthly_arpu / churn_rate).round # LTV in cents end def calculate_mrr_growth(period = DEFAULT_PERIOD) @@ -308,9 +343,15 @@ def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) end def calculate_mrr_at(date) + # Find subscriptions that were active AT the given date: + # - Created before or on that date + # - Not ended before that date (ends_at is nil OR ends_at > date) + # - Not paused at that date + # - Not in trialing status (trials don't count as MRR) Pay::Subscription - .active .where('pay_subscriptions.created_at <= ?', date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) + .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) .where.not(status: ['trialing', 'paused']) .includes(:customer) .select('pay_subscriptions.*, pay_customers.processor as customer_processor') diff --git a/lib/profitable/json_helpers.rb b/lib/profitable/json_helpers.rb new file mode 100644 index 0000000..12abbe3 --- /dev/null +++ b/lib/profitable/json_helpers.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Profitable + module JsonHelpers + # Regex patterns for validating SQL identifiers to prevent SQL injection + # Only allows: alphanumeric characters, underscores, and dots (for table.column format) + VALID_TABLE_COLUMN_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/ + VALID_JSON_KEY_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ + + # Returns the appropriate JSON extraction syntax for the current database adapter + # Supports PostgreSQL, MySQL (5.7.9+), and SQLite + # + # @param table_column [String] The table and column name (e.g., 'pay_charges.object') + # @param json_key [String] The JSON key to extract (e.g., 'paid', 'status') + # @return [String] Database-specific SQL for JSON extraction + # @raise [ArgumentError] if table_column or json_key contain invalid characters + # + # @example PostgreSQL + # json_extract('pay_charges.object', 'paid') + # # => "pay_charges.object ->> 'paid'" + # + # @example MySQL + # json_extract('pay_charges.object', 'paid') + # # => "JSON_UNQUOTE(JSON_EXTRACT(pay_charges.object, '$.paid'))" + # + # @example SQLite + # json_extract('pay_charges.object', 'paid') + # # => "json_extract(pay_charges.object, '$.paid')" + def json_extract(table_column, json_key) + # Validate inputs to prevent SQL injection + validate_table_column!(table_column) + validate_json_key!(json_key) + + adapter = ActiveRecord::Base.connection.adapter_name.downcase + + case adapter + when /postgres/ + "#{table_column} ->> '#{json_key}'" + when /mysql/, /trilogy/ + # MySQL 5.7.9+ supports JSON_EXTRACT and ->> operator + # We use JSON_UNQUOTE(JSON_EXTRACT()) for maximum compatibility + "JSON_UNQUOTE(JSON_EXTRACT(#{table_column}, '$.#{json_key}'))" + when /sqlite/ + "json_extract(#{table_column}, '$.#{json_key}')" + else + # Fallback to PostgreSQL syntax for unknown adapters + Rails.logger.warn("Unknown database adapter '#{adapter}' for JSON extraction. Falling back to PostgreSQL syntax.") + "#{table_column} ->> '#{json_key}'" + end + end + + private + + def validate_table_column!(table_column) + unless table_column.is_a?(String) && table_column.match?(VALID_TABLE_COLUMN_PATTERN) + raise ArgumentError, "Invalid table_column format: #{table_column.inspect}. " \ + "Must be alphanumeric with underscores/dots only (e.g., 'pay_charges.object')." + end + end + + def validate_json_key!(json_key) + unless json_key.is_a?(String) && json_key.match?(VALID_JSON_KEY_PATTERN) + raise ArgumentError, "Invalid json_key format: #{json_key.inspect}. " \ + "Must be alphanumeric with underscores only (e.g., 'paid', 'status')." + end + end + end +end diff --git a/lib/profitable/mrr_calculator.rb b/lib/profitable/mrr_calculator.rb index 9de5f92..5eb4ad5 100644 --- a/lib/profitable/mrr_calculator.rb +++ b/lib/profitable/mrr_calculator.rb @@ -27,9 +27,13 @@ def self.calculate end def self.process_subscription(subscription) - return 0 if subscription.nil? || subscription.data.nil? + return 0 if subscription.nil? + return 0 if subscription_data(subscription).nil? - processor_class = processor_for(subscription.customer_processor) + # Get processor from virtual attribute (set by .select() in queries) or from customer association + processor_name = subscription.try(:customer_processor) || subscription.customer&.processor + + processor_class = processor_for(processor_name) mrr = processor_class.new(subscription).calculate_mrr # Ensure MRR is a non-negative number @@ -39,6 +43,12 @@ def self.process_subscription(subscription) 0 end + # Pay gem v10+ stores Stripe objects in the `object` column, + # while older versions used `data`. This method provides backwards compatibility. + def self.subscription_data(subscription) + subscription.try(:object) || subscription.try(:data) + end + def self.processor_for(processor_name) case processor_name when 'stripe' diff --git a/lib/profitable/processors/base.rb b/lib/profitable/processors/base.rb index dc8149f..2920c9f 100644 --- a/lib/profitable/processors/base.rb +++ b/lib/profitable/processors/base.rb @@ -13,22 +13,45 @@ def calculate_mrr protected + # Pay gem v10+ stores Stripe objects in the `object` column, + # while older versions used `data`. This method provides backwards compatibility. + def subscription_data + subscription.try(:object) || subscription.try(:data) + end + + # Converts a billing amount to its monthly equivalent rate. + # + # Uses floating-point arithmetic for precision during calculation, + # then rounds to the nearest integer cent at the end. This approach + # ensures accurate rounding for fractional results (e.g., $100/year = $8.33/month = 833 cents). + # + # @param amount [Integer, String] The billing amount in cents + # @param interval [String] The billing interval ('day', 'week', 'month', 'year') + # @param interval_count [Integer, String] How many intervals per billing cycle + # @return [Integer] The monthly amount in cents (always a non-negative integer) def normalize_to_monthly(amount, interval, interval_count) return 0 if amount.nil? || interval.nil? || interval_count.nil? - case interval.to_s.downcase + # Ensure interval_count is converted to integer before division + interval_count_int = interval_count.to_i + return 0 if interval_count_int.zero? + + # Calculate using floats for precision, round at the end for integer cents + monthly_amount = case interval.to_s.downcase when 'day' - amount * 30.0 / interval_count + amount.to_f * 30 / interval_count_int when 'week' - amount * 4.0 / interval_count + amount.to_f * 4 / interval_count_int when 'month' - amount / interval_count + amount.to_f / interval_count_int when 'year' - amount / (12.0 * interval_count) + amount.to_f / (12 * interval_count_int) else Rails.logger.warn("Unknown interval for MRR calculation: #{interval}") 0 end + + monthly_amount.round # Return integer cents end end end diff --git a/lib/profitable/processors/braintree_processor.rb b/lib/profitable/processors/braintree_processor.rb index 83853a3..7af410b 100644 --- a/lib/profitable/processors/braintree_processor.rb +++ b/lib/profitable/processors/braintree_processor.rb @@ -2,10 +2,15 @@ module Profitable module Processors class BraintreeProcessor < Base def calculate_mrr - amount = subscription.data['price'] + data = subscription_data + return 0 if data.nil? + + amount = data['price'] + return 0 if amount.nil? + quantity = subscription.quantity || 1 - interval = subscription.data['billing_period_unit'] - interval_count = subscription.data['billing_period_frequency'] || 1 + interval = data['billing_period_unit'] + interval_count = data['billing_period_frequency'] || 1 normalize_to_monthly(amount * quantity, interval, interval_count) end diff --git a/lib/profitable/processors/paddle_billing_processor.rb b/lib/profitable/processors/paddle_billing_processor.rb index 843fc43..429d291 100644 --- a/lib/profitable/processors/paddle_billing_processor.rb +++ b/lib/profitable/processors/paddle_billing_processor.rb @@ -2,15 +2,30 @@ module Profitable module Processors class PaddleBillingProcessor < Base def calculate_mrr - price_data = subscription.data['items']&.first&.dig('price') - return 0 if price_data.nil? + data = subscription_data + return 0 if data.nil? - amount = price_data['unit_price']['amount'] - quantity = subscription.quantity || 1 - interval = price_data['billing_cycle']['interval'] - interval_count = price_data['billing_cycle']['frequency'] + items = data['items'] + return 0 if items.nil? || items.empty? - normalize_to_monthly(amount * quantity, interval, interval_count) + # Sum MRR from ALL subscription items + total_mrr = 0 + + items.each do |item| + price_data = item['price'] + next if price_data.nil? + + amount = price_data.dig('unit_price', 'amount') + next if amount.nil? + + item_quantity = item['quantity'] || 1 + interval = price_data.dig('billing_cycle', 'interval') + interval_count = price_data.dig('billing_cycle', 'frequency') + + total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count) + end + + total_mrr end end end diff --git a/lib/profitable/processors/paddle_classic_processor.rb b/lib/profitable/processors/paddle_classic_processor.rb index e785b6f..7a21478 100644 --- a/lib/profitable/processors/paddle_classic_processor.rb +++ b/lib/profitable/processors/paddle_classic_processor.rb @@ -2,9 +2,14 @@ module Profitable module Processors class PaddleClassicProcessor < Base def calculate_mrr - amount = subscription.data['recurring_price'] + data = subscription_data + return 0 if data.nil? + + amount = data['recurring_price'] + return 0 if amount.nil? + quantity = subscription.quantity || 1 - interval = subscription.data['recurring_interval'] + interval = data['recurring_interval'] interval_count = 1 # Paddle Classic doesn't have interval_count normalize_to_monthly(amount * quantity, interval, interval_count) diff --git a/lib/profitable/processors/stripe_processor.rb b/lib/profitable/processors/stripe_processor.rb index 19c1118..443eae1 100644 --- a/lib/profitable/processors/stripe_processor.rb +++ b/lib/profitable/processors/stripe_processor.rb @@ -2,18 +2,34 @@ module Profitable module Processors class StripeProcessor < Base def calculate_mrr - subscription_items = subscription.data['subscription_items'] + data = subscription_data + return 0 if data.nil? + + # Pay gem v10+ stores items at object['items']['data'] + # Older versions stored at data['subscription_items'] + subscription_items = data.dig('items', 'data') || data['subscription_items'] return 0 if subscription_items.nil? || subscription_items.empty? - price_data = subscription_items[0]['price'] - return 0 if price_data.nil? + # Sum MRR from ALL subscription items (not just the first one) + # Stripe subscriptions can have multiple line items + total_mrr = 0 + + subscription_items.each do |item| + price_data = item['price'] || item + next if price_data.nil? + + amount = price_data['unit_amount'] + next if amount.nil? + + # Each item can have its own quantity + item_quantity = item['quantity'] || 1 + interval = price_data.dig('recurring', 'interval') + interval_count = price_data.dig('recurring', 'interval_count') || 1 - amount = price_data['unit_amount'] - quantity = subscription.quantity || 1 - interval = price_data.dig('recurring', 'interval') - interval_count = price_data.dig('recurring', 'interval_count') || 1 + total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count) + end - normalize_to_monthly(amount * quantity, interval, interval_count) + total_mrr end end end diff --git a/test/json_helpers_test.rb b/test/json_helpers_test.rb new file mode 100644 index 0000000..ae30c09 --- /dev/null +++ b/test/json_helpers_test.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "test_helper" + +class JsonHelpersTest < Minitest::Test + # Create a test class that includes the JsonHelpers module + class TestClass + include Profitable::JsonHelpers + end + + def setup + @helper = TestClass.new + end + + # ============================================================================ + # VALID INPUTS + # ============================================================================ + + def test_accepts_valid_table_column_format + # Should not raise for valid table.column format + result = @helper.json_extract('pay_charges.object', 'paid') + refute_nil result + end + + def test_accepts_valid_single_column_format + result = @helper.json_extract('object', 'paid') + refute_nil result + end + + def test_accepts_valid_json_key + result = @helper.json_extract('pay_charges.object', 'status') + refute_nil result + end + + def test_accepts_json_key_with_underscores + result = @helper.json_extract('pay_charges.object', 'billing_period_unit') + refute_nil result + end + + def test_accepts_table_column_with_multiple_parts + result = @helper.json_extract('schema.table.column', 'key') + refute_nil result + end + + # ============================================================================ + # SQL INJECTION PREVENTION - TABLE_COLUMN VALIDATION + # ============================================================================ + + def test_rejects_sql_injection_in_table_column_with_quotes + assert_raises(ArgumentError) do + @helper.json_extract("pay_charges.object'; DROP TABLE users; --", 'paid') + end + end + + def test_rejects_sql_injection_in_table_column_with_semicolon + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object; DELETE FROM users', 'paid') + end + end + + def test_rejects_sql_injection_in_table_column_with_parentheses + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object)', 'paid') + end + end + + def test_rejects_sql_injection_in_table_column_with_dash_dash + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object --', 'paid') + end + end + + def test_rejects_table_column_starting_with_number + assert_raises(ArgumentError) do + @helper.json_extract('123table.column', 'paid') + end + end + + def test_rejects_empty_table_column + assert_raises(ArgumentError) do + @helper.json_extract('', 'paid') + end + end + + def test_rejects_nil_table_column + assert_raises(ArgumentError) do + @helper.json_extract(nil, 'paid') + end + end + + def test_rejects_table_column_with_spaces + assert_raises(ArgumentError) do + @helper.json_extract('pay charges.object', 'paid') + end + end + + # ============================================================================ + # SQL INJECTION PREVENTION - JSON_KEY VALIDATION + # ============================================================================ + + def test_rejects_sql_injection_in_json_key_with_quotes + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', "paid'; DROP TABLE users; --") + end + end + + def test_rejects_sql_injection_in_json_key_with_dollar_sign + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', '$.paid') + end + end + + def test_rejects_json_key_with_dots + # Dots are not allowed in json_key (only in table_column) + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', 'nested.key') + end + end + + def test_rejects_json_key_starting_with_number + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', '123key') + end + end + + def test_rejects_empty_json_key + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', '') + end + end + + def test_rejects_nil_json_key + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', nil) + end + end + + def test_rejects_json_key_with_spaces + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', 'paid status') + end + end + + def test_rejects_json_key_with_special_characters + assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', 'paid!') + end + end + + # ============================================================================ + # DATABASE ADAPTER OUTPUT + # ============================================================================ + + def test_generates_sqlite_syntax + # Our test environment uses SQLite + result = @helper.json_extract('pay_charges.object', 'paid') + + assert_equal "json_extract(pay_charges.object, '$.paid')", result + end + + # ============================================================================ + # ERROR MESSAGE QUALITY + # ============================================================================ + + def test_error_message_includes_invalid_value_for_table_column + error = assert_raises(ArgumentError) do + @helper.json_extract('invalid;column', 'paid') + end + + assert_includes error.message, 'invalid;column' + assert_includes error.message, 'table_column' + end + + def test_error_message_includes_invalid_value_for_json_key + error = assert_raises(ArgumentError) do + @helper.json_extract('pay_charges.object', 'invalid;key') + end + + assert_includes error.message, 'invalid;key' + assert_includes error.message, 'json_key' + end +end diff --git a/test/mrr_calculator_test.rb b/test/mrr_calculator_test.rb new file mode 100644 index 0000000..3c30f7b --- /dev/null +++ b/test/mrr_calculator_test.rb @@ -0,0 +1,413 @@ +# frozen_string_literal: true + +require "test_helper" + +class MrrCalculatorTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @stripe_customer = create_customer(processor: "stripe") + @braintree_customer = create_customer(processor: "braintree") + @paddle_billing_customer = create_customer(processor: "paddle_billing") + @paddle_classic_customer = create_customer(processor: "paddle_classic") + end + + # ============================================================================ + # CALCULATE: BASIC SCENARIOS + # ============================================================================ + + def test_calculate_returns_zero_when_no_subscriptions + assert_equal 0, Profitable::MrrCalculator.calculate + end + + def test_calculate_returns_mrr_for_single_subscription + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, # $99/month + interval: "month" + ) + + assert_equal 9900, Profitable::MrrCalculator.calculate + end + + def test_calculate_returns_sum_of_all_subscriptions + # Two $99/month subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 4900, # $49/month + interval: "month" + ) + + assert_equal 14800, Profitable::MrrCalculator.calculate # $148/month + end + + def test_calculate_sums_across_different_processors + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 5000, + interval: "month" + ) + create_braintree_subscription( + customer: @braintree_customer, + price: 3000, + interval: "month" + ) + create_paddle_billing_subscription( + customer: @paddle_billing_customer, + amount: 2000, + interval: "month" + ) + + assert_equal 10000, Profitable::MrrCalculator.calculate # $100/month + end + + # ============================================================================ + # CALCULATE: FILTERS (status, trialing, paused) + # ============================================================================ + + def test_calculate_excludes_trialing_subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "trialing" + ) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + + def test_calculate_excludes_paused_subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "paused" + ) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + + def test_calculate_excludes_canceled_subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + + def test_calculate_excludes_ended_subscriptions + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month", + status: "ended" + ) + + assert_equal 0, Profitable::MrrCalculator.calculate + end + + def test_calculate_includes_only_active_subscriptions + # Active: $50 + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 5000, + interval: "month", + status: "active" + ) + + # Trialing: $30 (should be excluded) + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 3000, + interval: "month", + status: "trialing" + ) + + # Canceled: $20 (should be excluded) + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 2000, + interval: "month", + status: "canceled" + ) + + assert_equal 5000, Profitable::MrrCalculator.calculate + end + + # ============================================================================ + # PROCESS_SUBSCRIPTION: PROCESSOR ROUTING + # ============================================================================ + + def test_process_subscription_routes_to_stripe_processor + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + assert_equal 9900, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_routes_to_braintree_processor + subscription = create_braintree_subscription( + customer: @braintree_customer, + price: 4900, + interval: "month" + ) + + assert_equal 4900, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_routes_to_paddle_billing_processor + subscription = create_paddle_billing_subscription( + customer: @paddle_billing_customer, + amount: 2900, + interval: "month" + ) + + assert_equal 2900, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_routes_to_paddle_classic_processor + subscription = create_paddle_classic_subscription( + customer: @paddle_classic_customer, + recurring_price: 1900, + interval: "month" + ) + + assert_equal 1900, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_uses_base_processor_for_unknown_processor + unknown_customer = create_customer(processor: "unknown_processor") + subscription = Pay::Subscription.create!( + customer: unknown_customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { "price" => 9900 } + ) + + # Base processor returns 0 + assert_equal 0, Profitable::MrrCalculator.process_subscription(subscription) + end + + # ============================================================================ + # PROCESS_SUBSCRIPTION: ERROR HANDLING + # ============================================================================ + + def test_process_subscription_returns_zero_for_nil_subscription + assert_equal 0, Profitable::MrrCalculator.process_subscription(nil) + end + + def test_process_subscription_returns_zero_for_nil_data + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + subscription.update!(object: nil, data: nil) + + assert_equal 0, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_returns_zero_for_negative_mrr + # This shouldn't happen in practice, but we guard against it + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + # Stub the processor to return a negative value + Profitable::Processors::StripeProcessor.any_instance.stubs(:calculate_mrr).returns(-1000) + + assert_equal 0, Profitable::MrrCalculator.process_subscription(subscription) + end + + def test_process_subscription_handles_exceptions_gracefully + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + # Stub the processor to raise an error + Profitable::Processors::StripeProcessor.any_instance.stubs(:calculate_mrr).raises(StandardError.new("Test error")) + + # Should return 0 instead of crashing + assert_equal 0, Profitable::MrrCalculator.process_subscription(subscription) + end + + # ============================================================================ + # SUBSCRIPTION_DATA: BACKWARDS COMPATIBILITY + # ============================================================================ + + def test_subscription_data_returns_object_when_present + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + data = Profitable::MrrCalculator.subscription_data(subscription) + + assert_equal subscription.object, data + end + + def test_subscription_data_returns_data_when_object_is_nil + subscription = create_stripe_subscription_legacy( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + data = Profitable::MrrCalculator.subscription_data(subscription) + + assert_equal subscription.data, data + end + + def test_subscription_data_returns_nil_when_both_nil + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + subscription.update!(object: nil, data: nil) + + data = Profitable::MrrCalculator.subscription_data(subscription) + + assert_nil data + end + + # ============================================================================ + # PROCESSOR_FOR: PROCESSOR CLASS SELECTION + # ============================================================================ + + def test_processor_for_returns_stripe_processor + assert_equal Profitable::Processors::StripeProcessor, + Profitable::MrrCalculator.processor_for("stripe") + end + + def test_processor_for_returns_braintree_processor + assert_equal Profitable::Processors::BraintreeProcessor, + Profitable::MrrCalculator.processor_for("braintree") + end + + def test_processor_for_returns_paddle_billing_processor + assert_equal Profitable::Processors::PaddleBillingProcessor, + Profitable::MrrCalculator.processor_for("paddle_billing") + end + + def test_processor_for_returns_paddle_classic_processor + assert_equal Profitable::Processors::PaddleClassicProcessor, + Profitable::MrrCalculator.processor_for("paddle_classic") + end + + def test_processor_for_returns_base_processor_for_unknown + assert_equal Profitable::Processors::Base, + Profitable::MrrCalculator.processor_for("unknown") + end + + def test_processor_for_returns_base_processor_for_nil + assert_equal Profitable::Processors::Base, + Profitable::MrrCalculator.processor_for(nil) + end + + # ============================================================================ + # REGRESSION: Pay v10+ object column support + # ============================================================================ + + def test_calculates_mrr_correctly_with_pay_v10_object_column + # REGRESSION TEST: This was the original bug + # Pay v10+ stores Stripe objects in `object` column, not `data` + subscription = create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + # Verify the subscription has object but not data + refute_nil subscription.object + assert_nil subscription.data + + # MRR should still calculate correctly + assert_equal 9900, Profitable::MrrCalculator.calculate + end + + def test_calculates_mrr_correctly_with_legacy_data_column + # REGRESSION TEST: Ensure backwards compatibility + subscription = create_stripe_subscription_legacy( + customer: @stripe_customer, + unit_amount: 9900, + interval: "month" + ) + + # Verify the subscription has data but not object + assert_nil subscription.object + refute_nil subscription.data + + # MRR should still calculate correctly + assert_equal 9900, Profitable::MrrCalculator.calculate + end + + # ============================================================================ + # EDGE CASES + # ============================================================================ + + def test_handles_large_number_of_subscriptions + # Create 100 subscriptions + 100.times do + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 100, # $1/month each + interval: "month" + ) + end + + assert_equal 10000, Profitable::MrrCalculator.calculate # $100/month total + end + + def test_handles_subscriptions_with_zero_amount + # A $0 subscription (free tier?) + Pay::Subscription.create!( + customer: @stripe_customer, + processor_id: "sub_free", + name: "default", + status: "active", + object: { + "items" => { + "data" => [ + { + "quantity" => 1, + "price" => { + "unit_amount" => 0, + "recurring" => { "interval" => "month", "interval_count" => 1 } + } + } + ] + } + } + ) + + # Another $50/month subscription + create_stripe_subscription_v10( + customer: @stripe_customer, + unit_amount: 5000, + interval: "month" + ) + + assert_equal 5000, Profitable::MrrCalculator.calculate + end +end diff --git a/test/numeric_result_test.rb b/test/numeric_result_test.rb new file mode 100644 index 0000000..712d3b1 --- /dev/null +++ b/test/numeric_result_test.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require "test_helper" + +class NumericResultTest < Minitest::Test + # ============================================================================ + # BASIC FUNCTIONALITY + # ============================================================================ + + def test_delegates_to_underlying_value + result = Profitable::NumericResult.new(1000) + + assert_equal 1000, result.to_i + assert_equal 1000.0, result.to_f + assert_equal "1000", result.to_s + end + + def test_responds_to_numeric_methods + result = Profitable::NumericResult.new(1000) + + assert result.respond_to?(:+) + assert result.respond_to?(:-) + assert result.respond_to?(:*) + assert result.respond_to?(:/) + end + + def test_arithmetic_operations + result = Profitable::NumericResult.new(1000) + + assert_equal 1500, result + 500 + assert_equal 500, result - 500 + assert_equal 2000, result * 2 + assert_equal 500, result / 2 + end + + # ============================================================================ + # CURRENCY TYPE (default) + # ============================================================================ + + def test_currency_to_readable_converts_cents_to_dollars + # 1000 cents = $10.00 + result = Profitable::NumericResult.new(1000) + + assert_equal "$10", result.to_readable(0) + end + + def test_currency_to_readable_with_precision + result = Profitable::NumericResult.new(1050) + + assert_equal "$10.5", result.to_readable(2) # Trailing zeros stripped + end + + def test_currency_to_readable_large_amounts + # $1,234,567.89 in cents + result = Profitable::NumericResult.new(123_456_789) + + assert_equal "$1,234,568", result.to_readable(0) + end + + def test_currency_zero_value + result = Profitable::NumericResult.new(0) + + assert_equal "$0", result.to_readable(0) + assert_equal "$0", result.to_readable(2) + end + + def test_currency_small_values + result = Profitable::NumericResult.new(99) # 99 cents + + assert_equal "$1", result.to_readable(0) # Rounds to $1 + assert_equal "$0.99", result.to_readable(2) + end + + def test_currency_negative_values + result = Profitable::NumericResult.new(-5000) # -$50 + + assert_equal "$-50", result.to_readable(0) + end + + # ============================================================================ + # PERCENTAGE TYPE + # ============================================================================ + + def test_percentage_to_readable + result = Profitable::NumericResult.new(5.25, :percentage) + + assert_equal "5.25%", result.to_readable(2) + end + + def test_percentage_to_readable_whole_number + result = Profitable::NumericResult.new(10, :percentage) + + assert_equal "10%", result.to_readable(0) + end + + def test_percentage_to_readable_zero + result = Profitable::NumericResult.new(0, :percentage) + + assert_equal "0%", result.to_readable(0) + end + + def test_percentage_to_readable_small_decimal + result = Profitable::NumericResult.new(0.5, :percentage) + + assert_equal "0.5%", result.to_readable(1) + end + + def test_percentage_to_readable_large_value + result = Profitable::NumericResult.new(150.75, :percentage) + + assert_equal "150.75%", result.to_readable(2) + end + + # ============================================================================ + # INTEGER TYPE + # ============================================================================ + + def test_integer_to_readable + result = Profitable::NumericResult.new(1234, :integer) + + assert_equal "1,234", result.to_readable + end + + def test_integer_to_readable_small_value + result = Profitable::NumericResult.new(5, :integer) + + assert_equal "5", result.to_readable + end + + def test_integer_to_readable_large_value + result = Profitable::NumericResult.new(1_234_567_890, :integer) + + assert_equal "1,234,567,890", result.to_readable + end + + def test_integer_to_readable_zero + result = Profitable::NumericResult.new(0, :integer) + + assert_equal "0", result.to_readable + end + + # ============================================================================ + # STRING TYPE + # ============================================================================ + + def test_string_to_readable + result = Profitable::NumericResult.new(42, :string) + + assert_equal "42", result.to_readable + end + + # ============================================================================ + # UNKNOWN TYPE (fallback) + # ============================================================================ + + def test_unknown_type_to_readable + result = Profitable::NumericResult.new(999, :unknown_type) + + assert_equal "999", result.to_readable + end + + # ============================================================================ + # EDGE CASES + # ============================================================================ + + def test_float_value + result = Profitable::NumericResult.new(1234.56) + + assert_in_delta 1234.56, result.to_f, 0.001 + end + + def test_comparison_with_numeric + result = Profitable::NumericResult.new(1000) + + assert result > 500 + assert result < 1500 + assert result == 1000 + assert result >= 1000 + assert result <= 1000 + end + + def test_comparison_with_another_numeric_result + result1 = Profitable::NumericResult.new(1000) + result2 = Profitable::NumericResult.new(2000) + + assert result2 > result1 + end + + def test_can_be_used_in_calculations + mrr = Profitable::NumericResult.new(10000) # $100 MRR + arr = mrr * 12 + + assert_equal 120000, arr + end + + def test_truthiness + zero_result = Profitable::NumericResult.new(0) + positive_result = Profitable::NumericResult.new(100) + + assert_equal true, zero_result.zero? + assert_equal false, positive_result.zero? + end +end diff --git a/test/processors/base_test.rb b/test/processors/base_test.rb new file mode 100644 index 0000000..0900911 --- /dev/null +++ b/test/processors/base_test.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProcessorsBaseTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @customer = create_customer(processor: "stripe") + @subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + @processor = Profitable::Processors::Base.new(@subscription) + end + + # ============================================================================ + # BASE CALCULATE_MRR (always returns 0, must be overridden) + # ============================================================================ + + def test_base_calculate_mrr_returns_zero + assert_equal 0, @processor.calculate_mrr + end + + # ============================================================================ + # SUBSCRIPTION_DATA BACKWARDS COMPATIBILITY + # ============================================================================ + + def test_subscription_data_returns_object_when_present + # v10+ uses object column + data = @processor.send(:subscription_data) + + assert_equal @subscription.object, data + end + + def test_subscription_data_returns_data_when_object_is_nil + # Pre-v10 uses data column + legacy_subscription = create_stripe_subscription_legacy( + customer: @customer, + unit_amount: 5000, + interval: "month" + ) + processor = Profitable::Processors::Base.new(legacy_subscription) + data = processor.send(:subscription_data) + + assert_equal legacy_subscription.data, data + end + + def test_subscription_data_returns_nil_when_both_nil + @subscription.update!(object: nil, data: nil) + data = @processor.send(:subscription_data) + + assert_nil data + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: MONTHLY INTERVALS + # ============================================================================ + + def test_normalize_to_monthly_for_monthly_subscription + # $99/month should return 9900 cents + result = @processor.send(:normalize_to_monthly, 9900, "month", 1) + + assert_equal 9900, result + end + + def test_normalize_to_monthly_for_quarterly_subscription + # $297/quarter = $99/month + result = @processor.send(:normalize_to_monthly, 29700, "month", 3) + + assert_equal 9900, result + end + + def test_normalize_to_monthly_for_biannual_subscription + # $594/6 months = $99/month + result = @processor.send(:normalize_to_monthly, 59400, "month", 6) + + assert_equal 9900, result + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: YEARLY INTERVALS + # ============================================================================ + + def test_normalize_to_monthly_for_yearly_subscription + # $1188/year = $99/month + result = @processor.send(:normalize_to_monthly, 118800, "year", 1) + + assert_equal 9900, result + end + + def test_normalize_to_monthly_for_biennial_subscription + # $2376/2 years = $99/month + result = @processor.send(:normalize_to_monthly, 237600, "year", 2) + + assert_equal 9900, result + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: WEEKLY INTERVALS + # ============================================================================ + + def test_normalize_to_monthly_for_weekly_subscription + # $25/week * 4 weeks = $100/month + result = @processor.send(:normalize_to_monthly, 2500, "week", 1) + + assert_equal 10000, result + end + + def test_normalize_to_monthly_for_biweekly_subscription + # $50/2 weeks * 2 = $100/month + result = @processor.send(:normalize_to_monthly, 5000, "week", 2) + + assert_equal 10000, result + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: DAILY INTERVALS + # ============================================================================ + + def test_normalize_to_monthly_for_daily_subscription + # $10/day * 30 days = $300/month + result = @processor.send(:normalize_to_monthly, 1000, "day", 1) + + assert_equal 30000, result + end + + def test_normalize_to_monthly_for_every_other_day_subscription + # $20/2 days * 15 = $150/month + result = @processor.send(:normalize_to_monthly, 2000, "day", 2) + + assert_equal 30000, result + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: EDGE CASES AND ERROR HANDLING + # ============================================================================ + + def test_normalize_to_monthly_returns_zero_for_nil_amount + result = @processor.send(:normalize_to_monthly, nil, "month", 1) + + assert_equal 0, result + end + + def test_normalize_to_monthly_returns_zero_for_nil_interval + result = @processor.send(:normalize_to_monthly, 9900, nil, 1) + + assert_equal 0, result + end + + def test_normalize_to_monthly_returns_zero_for_nil_interval_count + result = @processor.send(:normalize_to_monthly, 9900, "month", nil) + + assert_equal 0, result + end + + def test_normalize_to_monthly_returns_zero_for_zero_interval_count + # REGRESSION TEST: Prevent division by zero + result = @processor.send(:normalize_to_monthly, 9900, "month", 0) + + assert_equal 0, result + end + + def test_normalize_to_monthly_returns_zero_for_unknown_interval + result = @processor.send(:normalize_to_monthly, 9900, "decade", 1) + + assert_equal 0, result + end + + def test_normalize_to_monthly_handles_uppercase_interval + result = @processor.send(:normalize_to_monthly, 9900, "MONTH", 1) + + assert_equal 9900, result + end + + def test_normalize_to_monthly_handles_mixed_case_interval + result = @processor.send(:normalize_to_monthly, 9900, "Month", 1) + + assert_equal 9900, result + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: ROUNDING CONSISTENCY + # ============================================================================ + + def test_normalize_to_monthly_always_returns_integer + # REGRESSION TEST: Ensure consistent integer return (cents) + # $100/year = $8.333.../month = 833 cents (rounded) + result = @processor.send(:normalize_to_monthly, 10000, "year", 1) + + assert_kind_of Integer, result + assert_equal 833, result + end + + def test_normalize_to_monthly_rounds_correctly_for_odd_amounts + # $10/year = $0.833.../month = 83 cents (rounded) + result = @processor.send(:normalize_to_monthly, 1000, "year", 1) + + assert_kind_of Integer, result + assert_equal 83, result + end + + def test_normalize_to_monthly_handles_fractional_weekly + # $7/week * 4 = $28/month = 2800 cents + result = @processor.send(:normalize_to_monthly, 700, "week", 1) + + assert_kind_of Integer, result + assert_equal 2800, result + end + + # ============================================================================ + # NORMALIZE_TO_MONTHLY: STRING COERCION + # ============================================================================ + + def test_normalize_to_monthly_handles_string_amount + result = @processor.send(:normalize_to_monthly, "9900", "month", 1) + + assert_equal 9900, result + end + + def test_normalize_to_monthly_handles_string_interval_count + result = @processor.send(:normalize_to_monthly, 9900, "month", "3") + + assert_equal 3300, result + end + + def test_normalize_to_monthly_handles_symbol_interval + result = @processor.send(:normalize_to_monthly, 9900, :month, 1) + + assert_equal 9900, result + end +end diff --git a/test/processors/braintree_processor_test.rb b/test/processors/braintree_processor_test.rb new file mode 100644 index 0000000..561da6d --- /dev/null +++ b/test/processors/braintree_processor_test.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProcessorsBraintreeProcessorTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @customer = create_customer(processor: "braintree") + end + + # ============================================================================ + # BASIC MRR CALCULATION + # ============================================================================ + + def test_calculates_mrr_for_monthly_subscription + subscription = create_braintree_subscription( + customer: @customer, + price: 9900, # $99/month + interval: "month", + interval_count: 1 + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_yearly_subscription + subscription = create_braintree_subscription( + customer: @customer, + price: 118800, # $1188/year = $99/month + interval: "year", + interval_count: 1 + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_quarterly_subscription + subscription = create_braintree_subscription( + customer: @customer, + price: 29700, # $297/quarter = $99/month + interval: "month", + interval_count: 3 + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_weekly_subscription + subscription = create_braintree_subscription( + customer: @customer, + price: 2500, # $25/week * 4 = $100/month + interval: "week", + interval_count: 1 + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 10000, processor.calculate_mrr + end + + def test_calculates_mrr_for_daily_subscription + subscription = create_braintree_subscription( + customer: @customer, + price: 1000, # $10/day * 30 = $300/month + interval: "day", + interval_count: 1 + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 30000, processor.calculate_mrr + end + + # ============================================================================ + # QUANTITY HANDLING + # ============================================================================ + + def test_calculates_mrr_with_quantity + subscription = create_braintree_subscription( + customer: @customer, + price: 1000, # $10/month per seat + interval: "month", + quantity: 10 # 10 seats + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 10000, processor.calculate_mrr # $100/month + end + + def test_calculates_mrr_with_quantity_and_yearly_billing + subscription = create_braintree_subscription( + customer: @customer, + price: 12000, # $120/year per seat = $10/month per seat + interval: "year", + quantity: 5 # 5 seats + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr # $50/month + end + + # ============================================================================ + # EDGE CASES AND ERROR HANDLING + # ============================================================================ + + def test_returns_zero_for_nil_subscription_data + subscription = create_braintree_subscription( + customer: @customer, + price: 9900, + interval: "month" + ) + subscription.update!(object: nil, data: nil) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_returns_zero_for_nil_price + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "price" => nil, + "billing_period_unit" => "month", + "billing_period_frequency" => 1 + } + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_handles_missing_billing_period_frequency_defaults_to_one + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + quantity: 1, + status: "active", + object: { + "price" => 5000, + "billing_period_unit" => "month" + # No billing_period_frequency + } + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + def test_handles_missing_quantity_uses_subscription_quantity + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + quantity: 3, # Subscription-level quantity + status: "active", + object: { + "price" => 1000, + "billing_period_unit" => "month", + "billing_period_frequency" => 1 + } + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 3000, processor.calculate_mrr + end + + def test_handles_nil_quantity_defaults_to_one + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + quantity: nil, # No quantity + status: "active", + object: { + "price" => 5000, + "billing_period_unit" => "month", + "billing_period_frequency" => 1 + } + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + # ============================================================================ + # RETURN TYPE CONSISTENCY + # ============================================================================ + + def test_always_returns_integer + subscription = create_braintree_subscription( + customer: @customer, + price: 10000, # $100/year = $8.33/month = 833 cents + interval: "year" + ) + processor = Profitable::Processors::BraintreeProcessor.new(subscription) + result = processor.calculate_mrr + + assert_kind_of Integer, result + end +end diff --git a/test/processors/paddle_billing_processor_test.rb b/test/processors/paddle_billing_processor_test.rb new file mode 100644 index 0000000..bcfb482 --- /dev/null +++ b/test/processors/paddle_billing_processor_test.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProcessorsPaddleBillingProcessorTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @customer = create_customer(processor: "paddle_billing") + end + + # ============================================================================ + # BASIC MRR CALCULATION + # ============================================================================ + + def test_calculates_mrr_for_monthly_subscription + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 9900, # $99/month + interval: "month", + frequency: 1 + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_yearly_subscription + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 118800, # $1188/year = $99/month + interval: "year", + frequency: 1 + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_quarterly_subscription + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 29700, # $297/quarter = $99/month + interval: "month", + frequency: 3 + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_weekly_subscription + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 2500, # $25/week * 4 = $100/month + interval: "week", + frequency: 1 + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 10000, processor.calculate_mrr + end + + # ============================================================================ + # QUANTITY HANDLING + # ============================================================================ + + def test_calculates_mrr_with_quantity + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 1000, # $10/month per seat + interval: "month", + quantity: 5 # 5 seats + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr # $50/month + end + + # ============================================================================ + # MULTI-ITEM SUBSCRIPTIONS (REGRESSION TESTS) + # ============================================================================ + + def test_calculates_mrr_for_multi_item_subscription + # REGRESSION TEST: Ensure ALL items are summed, not just the first one + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 5000, # Base plan: $50/month + interval: "month", + additional_items: [ + { amount: 2000, quantity: 1 }, # Add-on 1: $20/month + { amount: 1000, quantity: 3 } # Add-on 2: $10/month * 3 = $30/month + ] + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + # Total: $50 + $20 + $30 = $100/month = 10000 cents + assert_equal 10000, processor.calculate_mrr + end + + def test_calculates_mrr_for_multi_item_with_different_intervals + # Mix of monthly and yearly items + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 5000, # Base: $50/month + interval: "month", + additional_items: [ + { amount: 12000, interval: "year", frequency: 1, quantity: 1 } # $120/year = $10/month + ] + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + # Total: $50 + $10 = $60/month = 6000 cents + assert_equal 6000, processor.calculate_mrr + end + + def test_calculates_mrr_for_subscription_with_many_items + # Stress test with many items + additional_items = 10.times.map do |i| + { amount: 100 * (i + 1), quantity: 1 } # $1, $2, $3... $10 + end + + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 1000, # Base: $10 + interval: "month", + additional_items: additional_items + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + # Total: $10 + (1+2+3+4+5+6+7+8+9+10) = $10 + $55 = $65 = 6500 cents + assert_equal 6500, processor.calculate_mrr + end + + # ============================================================================ + # EDGE CASES AND ERROR HANDLING + # ============================================================================ + + def test_returns_zero_for_nil_subscription_data + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 9900, + interval: "month" + ) + subscription.update!(object: nil, data: nil) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_returns_zero_for_nil_items + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { "items" => nil } + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_returns_zero_for_empty_items + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { "items" => [] } + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_skips_items_with_nil_price_data + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => [ + { "quantity" => 1, "price" => nil }, + { + "quantity" => 1, + "price" => { + "unit_price" => { "amount" => 3000 }, + "billing_cycle" => { "interval" => "month", "frequency" => 1 } + } + } + ] + } + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 3000, processor.calculate_mrr + end + + def test_skips_items_with_nil_amount + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => [ + { + "quantity" => 1, + "price" => { + "unit_price" => { "amount" => nil }, + "billing_cycle" => { "interval" => "month", "frequency" => 1 } + } + }, + { + "quantity" => 1, + "price" => { + "unit_price" => { "amount" => 5000 }, + "billing_cycle" => { "interval" => "month", "frequency" => 1 } + } + } + ] + } + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + def test_handles_missing_quantity_defaults_to_one + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => [ + { + # No quantity specified + "price" => { + "unit_price" => { "amount" => 5000 }, + "billing_cycle" => { "interval" => "month", "frequency" => 1 } + } + } + ] + } + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + # ============================================================================ + # RETURN TYPE CONSISTENCY + # ============================================================================ + + def test_always_returns_integer + subscription = create_paddle_billing_subscription( + customer: @customer, + amount: 10000, # $100/year = $8.33/month = 833 cents + interval: "year" + ) + processor = Profitable::Processors::PaddleBillingProcessor.new(subscription) + result = processor.calculate_mrr + + assert_kind_of Integer, result + end +end diff --git a/test/processors/paddle_classic_processor_test.rb b/test/processors/paddle_classic_processor_test.rb new file mode 100644 index 0000000..48d4a08 --- /dev/null +++ b/test/processors/paddle_classic_processor_test.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProcessorsPaddleClassicProcessorTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @customer = create_customer(processor: "paddle_classic") + end + + # ============================================================================ + # BASIC MRR CALCULATION + # ============================================================================ + + def test_calculates_mrr_for_monthly_subscription + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 9900, # $99/month + interval: "month" + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_yearly_subscription + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 118800, # $1188/year = $99/month + interval: "year" + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_weekly_subscription + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 2500, # $25/week * 4 = $100/month + interval: "week" + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 10000, processor.calculate_mrr + end + + def test_calculates_mrr_for_daily_subscription + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 1000, # $10/day * 30 = $300/month + interval: "day" + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 30000, processor.calculate_mrr + end + + # ============================================================================ + # QUANTITY HANDLING + # ============================================================================ + + def test_calculates_mrr_with_quantity + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 1000, # $10/month per seat + interval: "month", + quantity: 5 # 5 seats + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr # $50/month + end + + def test_calculates_mrr_with_quantity_and_yearly_billing + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 12000, # $120/year per seat = $10/month per seat + interval: "year", + quantity: 3 # 3 seats + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 3000, processor.calculate_mrr # $30/month + end + + # ============================================================================ + # EDGE CASES AND ERROR HANDLING + # ============================================================================ + + def test_returns_zero_for_nil_subscription_data + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 9900, + interval: "month" + ) + subscription.update!(object: nil, data: nil) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_returns_zero_for_nil_recurring_price + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "recurring_price" => nil, + "recurring_interval" => "month" + } + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_handles_nil_quantity_defaults_to_one + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + quantity: nil, + status: "active", + object: { + "recurring_price" => 5000, + "recurring_interval" => "month" + } + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + # ============================================================================ + # RETURN TYPE CONSISTENCY + # ============================================================================ + + def test_always_returns_integer + subscription = create_paddle_classic_subscription( + customer: @customer, + recurring_price: 10000, # $100/year = $8.33/month = 833 cents + interval: "year" + ) + processor = Profitable::Processors::PaddleClassicProcessor.new(subscription) + result = processor.calculate_mrr + + assert_kind_of Integer, result + end +end diff --git a/test/processors/stripe_processor_test.rb b/test/processors/stripe_processor_test.rb new file mode 100644 index 0000000..008e094 --- /dev/null +++ b/test/processors/stripe_processor_test.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProcessorsStripeProcessorTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @customer = create_customer(processor: "stripe") + end + + # ============================================================================ + # BASIC MRR CALCULATION (Pay v10+ with object column) + # ============================================================================ + + def test_calculates_mrr_for_monthly_subscription + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, # $99/month + interval: "month", + interval_count: 1 + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_yearly_subscription + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 118800, # $1188/year = $99/month + interval: "year", + interval_count: 1 + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_quarterly_subscription + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 29700, # $297/quarter = $99/month + interval: "month", + interval_count: 3 + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_weekly_subscription + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 2500, # $25/week * 4 = $100/month + interval: "week", + interval_count: 1 + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 10000, processor.calculate_mrr + end + + # ============================================================================ + # QUANTITY HANDLING + # ============================================================================ + + def test_calculates_mrr_with_quantity + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 1000, # $10/month per seat + interval: "month", + quantity: 5 # 5 seats + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr # $50/month + end + + def test_calculates_mrr_with_quantity_and_yearly_billing + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 12000, # $120/year per seat = $10/month per seat + interval: "year", + quantity: 3 # 3 seats + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 3000, processor.calculate_mrr # $30/month + end + + # ============================================================================ + # MULTI-ITEM SUBSCRIPTIONS (REGRESSION TESTS) + # ============================================================================ + + def test_calculates_mrr_for_multi_item_subscription + # REGRESSION TEST: Ensure ALL items are summed, not just the first one + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 5000, # Base plan: $50/month + interval: "month", + additional_items: [ + { unit_amount: 2000, quantity: 1 }, # Add-on 1: $20/month + { unit_amount: 1000, quantity: 3 } # Add-on 2: $10/month * 3 = $30/month + ] + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + # Total: $50 + $20 + $30 = $100/month = 10000 cents + assert_equal 10000, processor.calculate_mrr + end + + def test_calculates_mrr_for_multi_item_with_different_intervals + # Mix of monthly and yearly items + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 5000, # Base: $50/month + interval: "month", + additional_items: [ + { unit_amount: 12000, interval: "year", quantity: 1 } # $120/year = $10/month + ] + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + # Total: $50 + $10 = $60/month = 6000 cents + assert_equal 6000, processor.calculate_mrr + end + + def test_calculates_mrr_for_subscription_with_many_items + # Stress test with many items + additional_items = 10.times.map do |i| + { unit_amount: 100 * (i + 1), quantity: 1 } # $1, $2, $3... $10 + end + + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 1000, # Base: $10 + interval: "month", + additional_items: additional_items + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + # Total: $10 + (1+2+3+4+5+6+7+8+9+10) = $10 + $55 = $65 = 6500 cents + assert_equal 6500, processor.calculate_mrr + end + + # ============================================================================ + # LEGACY DATA COLUMN SUPPORT (Pay pre-v10) + # ============================================================================ + + def test_calculates_mrr_for_legacy_subscription_data_column + # REGRESSION TEST: Ensure backwards compatibility with data column + subscription = create_stripe_subscription_legacy( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + def test_calculates_mrr_for_legacy_yearly_subscription + subscription = create_stripe_subscription_legacy( + customer: @customer, + unit_amount: 118800, # $1188/year + interval: "year" + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 9900, processor.calculate_mrr + end + + # ============================================================================ + # EDGE CASES AND ERROR HANDLING + # ============================================================================ + + def test_returns_zero_for_nil_subscription_data + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + subscription.update!(object: nil, data: nil) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_returns_zero_for_empty_subscription_items + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => { "data" => [] } + } + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_returns_zero_for_nil_subscription_items + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => { "data" => nil } + } + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 0, processor.calculate_mrr + end + + def test_skips_items_with_nil_unit_amount + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => { + "data" => [ + { + "quantity" => 1, + "price" => { + "unit_amount" => nil, + "recurring" => { "interval" => "month", "interval_count" => 1 } + } + }, + { + "quantity" => 1, + "price" => { + "unit_amount" => 5000, + "recurring" => { "interval" => "month", "interval_count" => 1 } + } + } + ] + } + } + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + # Should only count the valid item: $50 + assert_equal 5000, processor.calculate_mrr + end + + def test_skips_items_with_nil_price_data + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => { + "data" => [ + { "quantity" => 1, "price" => nil }, + { + "quantity" => 1, + "price" => { + "unit_amount" => 3000, + "recurring" => { "interval" => "month", "interval_count" => 1 } + } + } + ] + } + } + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 3000, processor.calculate_mrr + end + + def test_handles_missing_quantity_defaults_to_one + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => { + "data" => [ + { + # No quantity specified + "price" => { + "unit_amount" => 5000, + "recurring" => { "interval" => "month", "interval_count" => 1 } + } + } + ] + } + } + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + def test_handles_missing_interval_count_defaults_to_one + subscription = Pay::Subscription.create!( + customer: @customer, + processor_id: "sub_test", + name: "default", + status: "active", + object: { + "items" => { + "data" => [ + { + "quantity" => 1, + "price" => { + "unit_amount" => 5000, + "recurring" => { "interval" => "month" } # No interval_count + } + } + ] + } + } + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + + assert_equal 5000, processor.calculate_mrr + end + + # ============================================================================ + # RETURN TYPE CONSISTENCY + # ============================================================================ + + def test_always_returns_integer + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, # $100/year = $8.33/month = 833 cents + interval: "year" + ) + processor = Profitable::Processors::StripeProcessor.new(subscription) + result = processor.calculate_mrr + + assert_kind_of Integer, result + end +end diff --git a/test/profitable_test.rb b/test/profitable_test.rb new file mode 100644 index 0000000..aadaa85 --- /dev/null +++ b/test/profitable_test.rb @@ -0,0 +1,670 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProfitableTest < Minitest::Test + # ============================================================================ + # SETUP + # ============================================================================ + + def setup + super + @customer = create_customer(processor: "stripe") + end + + # ============================================================================ + # MRR (Monthly Recurring Revenue) + # ============================================================================ + + def test_mrr_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.mrr + end + + def test_mrr_returns_zero_when_no_subscriptions + assert_equal 0, Profitable.mrr.to_i + end + + def test_mrr_calculates_correctly + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + assert_equal 9900, Profitable.mrr.to_i + end + + def test_mrr_to_readable_formats_as_currency + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + assert_equal "$99", Profitable.mrr.to_readable + end + + # ============================================================================ + # ARR (Annual Recurring Revenue) + # ============================================================================ + + def test_arr_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.arr + end + + def test_arr_is_twelve_times_mrr + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, # $100/month + interval: "month" + ) + + # MRR: $100/month = 10000 cents + # ARR: $1200/year = 120000 cents + assert_equal 120000, Profitable.arr.to_i + end + + def test_arr_returns_integer + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, # $99/month + interval: "month" + ) + + # REGRESSION TEST: ARR should return integer cents + result = Profitable.arr.to_i + + assert_kind_of Integer, result + assert_equal 118800, result # $1188/year + end + + # ============================================================================ + # CHURN + # ============================================================================ + + def test_churn_returns_numeric_result_with_percentage_type + result = Profitable.churn + + assert_kind_of Profitable::NumericResult, result + assert_equal "0%", result.to_readable(0) + end + + def test_churn_returns_zero_when_no_subscribers + assert_equal 0, Profitable.churn.to_f + end + + def test_churn_calculates_percentage + # Create a customer who was subscribed and then churned + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + + # Backdate the subscription to 60 days ago + subscription.update!(created_at: 60.days.ago) + + # Create another customer who churned (subscription ended 15 days ago) + churned_customer = create_customer(processor: "stripe") + churned_sub = create_stripe_subscription_v10( + customer: churned_customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 45.days.ago, + ends_at: 15.days.ago + ) + + # Churn = churned customers / total subscribers at start of period + churn = Profitable.churn(in_the_last: 30.days).to_f + + assert churn > 0, "Churn should be greater than 0" + assert churn <= 100, "Churn should be <= 100%" + end + + # ============================================================================ + # ALL TIME REVENUE + # ============================================================================ + + def test_all_time_revenue_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.all_time_revenue + end + + def test_all_time_revenue_sums_all_successful_charges + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge(customer: @customer, amount: 3000) + create_successful_charge(customer: @customer, amount: 2000) + + assert_equal 10000, Profitable.all_time_revenue.to_i + end + + def test_all_time_revenue_excludes_failed_charges + create_successful_charge(customer: @customer, amount: 5000) + create_failed_charge(customer: @customer, amount: 10000) + + assert_equal 5000, Profitable.all_time_revenue.to_i + end + + # ============================================================================ + # REVENUE IN PERIOD + # ============================================================================ + + def test_revenue_in_period_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.revenue_in_period + end + + def test_revenue_in_period_only_includes_charges_in_period + # Charge from 60 days ago (outside default 30 day period) + old_charge = create_successful_charge(customer: @customer, amount: 10000) + old_charge.update!(created_at: 60.days.ago) + + # Recent charges + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge(customer: @customer, amount: 3000) + + assert_equal 8000, Profitable.revenue_in_period(in_the_last: 30.days).to_i + end + + # ============================================================================ + # RECURRING REVENUE IN PERIOD + # ============================================================================ + + def test_recurring_revenue_in_period_only_includes_subscription_charges + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + # Subscription charge + create_successful_charge(customer: @customer, amount: 9900, subscription: subscription) + + # One-time charge (not recurring) + create_successful_charge(customer: @customer, amount: 5000) + + assert_equal 9900, Profitable.recurring_revenue_in_period(in_the_last: 30.days).to_i + end + + # ============================================================================ + # RECURRING REVENUE PERCENTAGE + # ============================================================================ + + def test_recurring_revenue_percentage_returns_percentage + result = Profitable.recurring_revenue_percentage + + assert_kind_of Profitable::NumericResult, result + end + + def test_recurring_revenue_percentage_calculates_correctly + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + # $75 in subscription revenue + create_successful_charge(customer: @customer, amount: 7500, subscription: subscription) + + # $25 in one-time revenue + create_successful_charge(customer: @customer, amount: 2500) + + # 75% recurring + percentage = Profitable.recurring_revenue_percentage(in_the_last: 30.days).to_f + + assert_equal 75.0, percentage + end + + def test_recurring_revenue_percentage_returns_zero_when_no_revenue + assert_equal 0, Profitable.recurring_revenue_percentage.to_f + end + + # ============================================================================ + # ESTIMATED VALUATION + # ============================================================================ + + def test_estimated_valuation_uses_default_multiplier_of_3x + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, # $100/month MRR + interval: "month" + ) + + # ARR = $1200, Valuation @ 3x = $3600 = 360000 cents + assert_equal 360000, Profitable.estimated_valuation.to_i + end + + def test_estimated_valuation_accepts_custom_multiplier + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, # $100/month MRR + interval: "month" + ) + + # ARR = $1200, Valuation @ 5x = $6000 = 600000 cents + assert_equal 600000, Profitable.estimated_valuation(5).to_i + end + + def test_estimated_valuation_accepts_at_keyword + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + assert_equal 600000, Profitable.estimated_valuation(at: 5).to_i + end + + def test_estimated_valuation_accepts_multiple_keyword + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + assert_equal 600000, Profitable.estimated_valuation(multiple: 5).to_i + end + + def test_estimated_valuation_accepts_string_multiplier_with_x + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + assert_equal 480000, Profitable.estimated_valuation("4x").to_i + end + + def test_estimated_valuation_clamps_multiplier_range + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + # Very high multiplier should be clamped to 100 + high_valuation = Profitable.estimated_valuation(200).to_i + expected = (120000 * 100) # ARR * 100 + + assert_equal expected, high_valuation + end + + # ============================================================================ + # SUBSCRIBER COUNTS + # ============================================================================ + + def test_total_customers_counts_customers_with_charges + create_successful_charge(customer: @customer, amount: 5000) + + customer2 = create_customer(processor: "stripe") + create_successful_charge(customer: customer2, amount: 3000) + + assert_equal 2, Profitable.total_customers.to_i + end + + def test_total_subscribers_counts_customers_with_subscriptions + create_stripe_subscription_v10(customer: @customer, unit_amount: 9900, interval: "month") + + customer2 = create_customer(processor: "stripe") + create_stripe_subscription_v10(customer: customer2, unit_amount: 4900, interval: "month") + + assert_equal 2, Profitable.total_subscribers.to_i + end + + def test_active_subscribers_counts_only_active_subscriptions + # Active subscription + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + + # Canceled subscription + customer2 = create_customer(processor: "stripe") + create_stripe_subscription_v10( + customer: customer2, + unit_amount: 4900, + interval: "month", + status: "canceled" + ) + + assert_equal 1, Profitable.active_subscribers.to_i + end + + # ============================================================================ + # NEW CUSTOMERS AND SUBSCRIBERS + # ============================================================================ + + def test_new_customers_in_period + # Customer created 60 days ago (outside period) + old_customer = create_customer(processor: "stripe") + old_customer.update!(created_at: 60.days.ago) + create_successful_charge(customer: old_customer, amount: 5000) + + # New customer + new_customer = create_customer(processor: "stripe") + create_successful_charge(customer: new_customer, amount: 3000) + + assert_equal 1, Profitable.new_customers(in_the_last: 30.days).to_i + end + + def test_new_subscribers_counts_new_subscriptions_in_period + # REGRESSION TEST: Should count by subscription created_at, not customer created_at + old_subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + old_subscription.update!(created_at: 60.days.ago) + + # New subscription + new_customer = create_customer(processor: "stripe") + create_stripe_subscription_v10( + customer: new_customer, + unit_amount: 4900, + interval: "month" + ) + + assert_equal 1, Profitable.new_subscribers(in_the_last: 30.days).to_i + end + + # ============================================================================ + # CHURNED CUSTOMERS + # ============================================================================ + + def test_churned_customers_counts_ended_subscriptions + # Active subscription + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "active" + ) + + # Churned subscription + churned_customer = create_customer(processor: "stripe") + churned_sub = create_stripe_subscription_v10( + customer: churned_customer, + unit_amount: 4900, + interval: "month", + status: "canceled" + ) + churned_sub.update!(ends_at: 10.days.ago) + + assert_equal 1, Profitable.churned_customers(in_the_last: 30.days).to_i + end + + # ============================================================================ + # NEW MRR + # ============================================================================ + + def test_new_mrr_calculates_mrr_from_new_subscriptions + # Old subscription (outside period) + old_sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 5000, + interval: "month" + ) + old_sub.update!(created_at: 60.days.ago) + + # New subscription + new_customer = create_customer(processor: "stripe") + create_stripe_subscription_v10( + customer: new_customer, + unit_amount: 9900, + interval: "month" + ) + + # REGRESSION TEST: Should be full MRR, not prorated + assert_equal 9900, Profitable.new_mrr(in_the_last: 30.days).to_i + end + + def test_new_mrr_excludes_trialing_subscriptions + # New trialing subscription + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "trialing" + ) + + assert_equal 0, Profitable.new_mrr(in_the_last: 30.days).to_i + end + + # ============================================================================ + # CHURNED MRR + # ============================================================================ + + def test_churned_mrr_calculates_mrr_lost + # Churned subscription + churned_sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 45.days.ago, + ends_at: 10.days.ago + ) + + # REGRESSION TEST: Should be full MRR, not prorated + assert_equal 9900, Profitable.churned_mrr(in_the_last: 30.days).to_i + end + + # ============================================================================ + # AVERAGE REVENUE PER CUSTOMER + # ============================================================================ + + def test_average_revenue_per_customer_calculates_correctly + # Customer 1: $100 total + create_successful_charge(customer: @customer, amount: 10000) + + # Customer 2: $50 total + customer2 = create_customer(processor: "stripe") + create_successful_charge(customer: customer2, amount: 5000) + + # Average: $75 = 7500 cents + assert_equal 7500, Profitable.average_revenue_per_customer.to_i + end + + def test_average_revenue_per_customer_returns_zero_when_no_customers + assert_equal 0, Profitable.average_revenue_per_customer.to_i + end + + # ============================================================================ + # LIFETIME VALUE (LTV) + # ============================================================================ + + def test_lifetime_value_returns_numeric_result + assert_kind_of Profitable::NumericResult, Profitable.lifetime_value + end + + def test_lifetime_value_returns_zero_when_no_subscribers + assert_equal 0, Profitable.lifetime_value.to_i + end + + def test_lifetime_value_returns_zero_when_no_churn + # Active subscription with no churn + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + # LTV = ARPU / churn_rate + # When churn is 0, LTV should return 0 (not infinity) + assert_equal 0, Profitable.lifetime_value.to_i + end + + def test_lifetime_value_calculates_correctly + # REGRESSION TEST: LTV = Monthly ARPU / Monthly Churn Rate + # Setup: 2 active subscriptions at $99/month, 1 churned + subscription1 = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + subscription1.update!(created_at: 60.days.ago) + + customer2 = create_customer(processor: "stripe") + subscription2 = create_stripe_subscription_v10( + customer: customer2, + unit_amount: 9900, + interval: "month" + ) + subscription2.update!(created_at: 60.days.ago) + + churned_customer = create_customer(processor: "stripe") + churned_sub = create_stripe_subscription_v10( + customer: churned_customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 60.days.ago, + ends_at: 15.days.ago + ) + + ltv = Profitable.lifetime_value.to_i + + # LTV should be calculated as: ARPU / churn_rate + # ARPU = MRR / active_subscribers = 19800 / 2 = 9900 + # Churn = 1 churned / 3 at start = 33.33% + # LTV = 9900 / 0.3333 ≈ 29700 + assert ltv > 0, "LTV should be positive when there is churn" + end + + # ============================================================================ + # MRR GROWTH + # ============================================================================ + + def test_mrr_growth_returns_new_mrr_minus_churned_mrr + # New subscription + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + + # Churned subscription + churned_customer = create_customer(processor: "stripe") + churned_sub = create_stripe_subscription_v10( + customer: churned_customer, + unit_amount: 5000, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 45.days.ago, + ends_at: 15.days.ago + ) + + # Net MRR growth = $100 new - $50 churned = $50 + assert_equal 5000, Profitable.mrr_growth(in_the_last: 30.days).to_i + end + + def test_mrr_growth_can_be_negative + # Churned subscription (no new subscriptions) + churned_sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 45.days.ago, + ends_at: 15.days.ago + ) + + assert_equal(-9900, Profitable.mrr_growth(in_the_last: 30.days).to_i) + end + + # ============================================================================ + # MRR GROWTH RATE + # ============================================================================ + + def test_mrr_growth_rate_returns_percentage + result = Profitable.mrr_growth_rate + + assert_kind_of Profitable::NumericResult, result + end + + def test_mrr_growth_rate_returns_zero_when_no_starting_mrr + # No subscriptions 30 days ago + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + assert_equal 0, Profitable.mrr_growth_rate(in_the_last: 30.days).to_f + end + + # ============================================================================ + # TIME TO NEXT MRR MILESTONE + # ============================================================================ + + def test_time_to_next_mrr_milestone_returns_message_when_no_mrr + message = Profitable.time_to_next_mrr_milestone + + assert_equal "Unable to calculate. No MRR yet.", message + end + + def test_time_to_next_mrr_milestone_returns_congratulations_at_highest_milestone + # Create subscription with $100M+ MRR (way above highest milestone) + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 15_000_000_000, # $150M/month + interval: "month" + ) + + message = Profitable.time_to_next_mrr_milestone + + assert_equal "Congratulations! You've reached the highest milestone.", message + end + + def test_time_to_next_mrr_milestone_returns_message_when_no_growth + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 100000, # $1000/month + interval: "month" + ) + # Backdate to have same MRR 30 days ago + subscription.update!(created_at: 60.days.ago) + + message = Profitable.time_to_next_mrr_milestone + + assert_includes message, "Unable to calculate" + end + + # ============================================================================ + # REGRESSION: paid_charges backwards compatibility + # ============================================================================ + + def test_paid_charges_works_with_v10_object_column + # REGRESSION TEST: Charge with status in `object` column + create_successful_charge(customer: @customer, amount: 5000) + + assert_equal 5000, Profitable.all_time_revenue.to_i + end + + def test_paid_charges_works_with_legacy_data_column + # REGRESSION TEST: Charge with status in `data` column + create_successful_charge_legacy(customer: @customer, amount: 3000) + + assert_equal 3000, Profitable.all_time_revenue.to_i + end + + def test_paid_charges_works_with_mixed_columns + # Both v10+ and legacy charges + create_successful_charge(customer: @customer, amount: 5000) + create_successful_charge_legacy(customer: @customer, amount: 3000) + + assert_equal 8000, Profitable.all_time_revenue.to_i + end +end diff --git a/test/regression_test.rb b/test/regression_test.rb new file mode 100644 index 0000000..55e00f7 --- /dev/null +++ b/test/regression_test.rb @@ -0,0 +1,513 @@ +# frozen_string_literal: true + +require "test_helper" + +# ============================================================================= +# REGRESSION TESTS +# ============================================================================= +# +# This file contains regression tests for all bugs that were fixed in the +# profitable gem. Each section documents the original bug and ensures we +# don't regress to it in the future. +# +# These tests are CRITICAL for maintaining the accuracy of business metrics. +# ============================================================================= + +class RegressionTest < Minitest::Test + def setup + super + @customer = create_customer(processor: "stripe") + end + + # =========================================================================== + # BUG #1: Pay v10+ object column not being read + # =========================================================================== + # + # ORIGINAL BUG: The profitable gem was checking `subscription.data` for + # subscription details, but Pay gem v10+ stores the full Stripe object in + # the `object` column instead. This caused MRR to always return 0 for + # users on Pay v10+. + # + # FIX: Added `subscription_data` helper that checks `object` first, then + # falls back to `data` for backwards compatibility. + # =========================================================================== + + def test_bug1_mrr_works_with_pay_v10_object_column + # Create subscription using v10+ structure (object column) + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + # Verify the data structure + refute_nil subscription.object + assert_nil subscription.data + refute_nil subscription.object.dig("items", "data") + + # MRR should be calculated correctly + mrr = Profitable.mrr.to_i + + assert_equal 9900, mrr, "BUG #1 REGRESSION: MRR should be 9900 cents, not 0" + end + + def test_bug1_mrr_still_works_with_legacy_data_column + # Create subscription using pre-v10 structure (data column) + subscription = create_stripe_subscription_legacy( + customer: @customer, + unit_amount: 4900, + interval: "month" + ) + + # Verify the data structure + assert_nil subscription.object + refute_nil subscription.data + refute_nil subscription.data["subscription_items"] + + # MRR should be calculated correctly + mrr = Profitable.mrr.to_i + + assert_equal 4900, mrr, "BUG #1 REGRESSION: Legacy data column should still work" + end + + # =========================================================================== + # BUG #2: Multi-item subscriptions only counting first item + # =========================================================================== + # + # ORIGINAL BUG: StripeProcessor and PaddleBillingProcessor were only + # processing the first subscription item, ignoring additional items. + # This caused MRR to be underreported for subscriptions with add-ons. + # + # FIX: Changed from accessing `subscription_items[0]` to iterating and + # summing MRR from ALL subscription items. + # =========================================================================== + + def test_bug2_stripe_multi_item_subscriptions_sum_all_items + # Create a subscription with base plan + 2 add-ons + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 5000, # Base: $50/month + interval: "month", + additional_items: [ + { unit_amount: 2000, quantity: 1 }, # Add-on 1: $20/month + { unit_amount: 1000, quantity: 3 } # Add-on 2: $10 * 3 = $30/month + ] + ) + + mrr = Profitable.mrr.to_i + + # Should be $50 + $20 + $30 = $100/month = 10000 cents + assert_equal 10000, mrr, "BUG #2 REGRESSION: Should sum ALL items, not just first" + end + + def test_bug2_paddle_billing_multi_item_subscriptions_sum_all_items + customer = create_customer(processor: "paddle_billing") + + subscription = create_paddle_billing_subscription( + customer: customer, + amount: 3000, # Base: $30/month + interval: "month", + additional_items: [ + { amount: 1500, quantity: 2 }, # Add-on: $15 * 2 = $30/month + ] + ) + + mrr = Profitable.mrr.to_i + + # Should be $30 + $30 = $60/month = 6000 cents + assert_equal 6000, mrr, "BUG #2 REGRESSION: Paddle should also sum all items" + end + + # =========================================================================== + # BUG #3: Division by zero in normalize_to_monthly + # =========================================================================== + # + # ORIGINAL BUG: When interval_count was 0 or nil, the normalize_to_monthly + # method would cause a division by zero error. + # + # FIX: Added check for `interval_count.to_i.zero?` that returns 0 instead + # of attempting the division. + # =========================================================================== + + def test_bug3_normalize_to_monthly_handles_zero_interval_count + processor = Profitable::Processors::Base.new(@customer) + + # Should return 0, not raise ZeroDivisionError + result = processor.send(:normalize_to_monthly, 9900, "month", 0) + + assert_equal 0, result, "BUG #3 REGRESSION: Should return 0, not crash" + end + + def test_bug3_normalize_to_monthly_handles_nil_values + processor = Profitable::Processors::Base.new(@customer) + + # None of these should raise errors + assert_equal 0, processor.send(:normalize_to_monthly, nil, "month", 1) + assert_equal 0, processor.send(:normalize_to_monthly, 9900, nil, 1) + assert_equal 0, processor.send(:normalize_to_monthly, 9900, "month", nil) + end + + # =========================================================================== + # BUG #4: Incorrect LTV formula + # =========================================================================== + # + # ORIGINAL BUG: LTV calculation was using an incorrect formula that didn't + # match the standard LTV = ARPU / Churn Rate calculation. + # + # FIX: Updated formula to: LTV = (MRR / active_subscribers) / churn_rate + # =========================================================================== + + def test_bug4_ltv_uses_correct_formula + # Setup: 2 active subscribers at $100/month each, 50% churn + customer1 = create_customer(processor: "stripe") + sub1 = create_stripe_subscription_v10( + customer: customer1, + unit_amount: 10000, + interval: "month" + ) + sub1.update!(created_at: 60.days.ago) + + customer2 = create_customer(processor: "stripe") + sub2 = create_stripe_subscription_v10( + customer: customer2, + unit_amount: 10000, + interval: "month" + ) + sub2.update!(created_at: 60.days.ago) + + # 1 churned (50% churn in 30 days from 2 starting subscribers) + churned_customer = create_customer(processor: "stripe") + churned_sub = create_stripe_subscription_v10( + customer: churned_customer, + unit_amount: 10000, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 60.days.ago, + ends_at: 15.days.ago + ) + + ltv = Profitable.lifetime_value.to_i + + # MRR = $200 (2 active * $100) + # ARPU = $200 / 2 = $100 = 10000 cents + # Churn = 1/3 = 33.33% + # LTV = 10000 / 0.3333 ≈ 30000 cents + + assert ltv > 0, "BUG #4 REGRESSION: LTV should be positive" + assert ltv < 100000, "BUG #4 REGRESSION: LTV should be reasonable" + end + + def test_bug4_ltv_returns_zero_for_zero_churn + # Active subscription with no churn + sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month" + ) + sub.update!(created_at: 60.days.ago) + + ltv = Profitable.lifetime_value.to_i + + # When churn is 0, LTV would be infinity + # We should return 0 instead of crashing + assert_equal 0, ltv, "BUG #4 REGRESSION: LTV should be 0 when churn is 0" + end + + # =========================================================================== + # BUG #5: Historical date calculations using current status + # =========================================================================== + # + # ORIGINAL BUG: calculate_mrr_at and calculate_churn were using the current + # subscription status and active scope instead of determining what was + # active AT the historical date. + # + # FIX: Updated queries to check created_at, ends_at, and pause_starts_at + # relative to the historical date. + # =========================================================================== + + def test_bug5_mrr_at_calculates_historical_state + # Subscription that was active 60 days ago but churned 30 days ago + churned_sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 5000, + interval: "month", + status: "canceled" + ) + churned_sub.update!( + created_at: 90.days.ago, + ends_at: 30.days.ago + ) + + # New subscription created 15 days ago + new_sub = create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 7500, + interval: "month" + ) + new_sub.update!(created_at: 15.days.ago) + + # MRR at 45 days ago should include churned_sub but NOT new_sub + historical_mrr = Profitable.send(:calculate_mrr_at, 45.days.ago) + + assert_equal 5000, historical_mrr, "BUG #5 REGRESSION: Should calculate MRR at historical date" + end + + def test_bug5_churn_uses_subscribers_at_start_of_period + # 3 subscriptions active 45 days ago + old_subs = 3.times.map do + customer = create_customer(processor: "stripe") + sub = create_stripe_subscription_v10( + customer: customer, + unit_amount: 5000, + interval: "month" + ) + sub.update!(created_at: 60.days.ago) + sub + end + + # 1 of them churned 15 days ago + old_subs[0].update!(status: "canceled", ends_at: 15.days.ago) + + # 2 new subscriptions created recently (shouldn't affect denominator) + 2.times do + create_stripe_subscription_v10( + customer: create_customer(processor: "stripe"), + unit_amount: 5000, + interval: "month" + ) + end + + churn = Profitable.churn(in_the_last: 30.days).to_f + + # Churn should be 1/3 = 33.33% (not 1/5 = 20%) + assert_in_delta 33.33, churn, 1.0, "BUG #5 REGRESSION: Churn should use start-of-period subscribers" + end + + # =========================================================================== + # BUG #6: Proration incorrectly applied to MRR calculations + # =========================================================================== + # + # ORIGINAL BUG: calculate_new_mrr and calculate_churned_mrr were prorating + # the MRR based on what portion of the period the subscription was active. + # However, MRR is a RATE, not revenue, so proration doesn't make sense. + # + # FIX: Removed proration logic. New/churned MRR now reflects the full + # monthly rate of subscriptions that started/ended in the period. + # =========================================================================== + + def test_bug6_new_mrr_is_full_rate_not_prorated + # Subscription created 5 days ago (would have been ~17% prorated) + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, # $100/month + interval: "month" + ) + subscription.update!(created_at: 5.days.ago) + + new_mrr = Profitable.new_mrr(in_the_last: 30.days).to_i + + # Should be full $100, not prorated $17 + assert_equal 10000, new_mrr, "BUG #6 REGRESSION: New MRR should be full rate" + end + + def test_bug6_churned_mrr_is_full_rate_not_prorated + # Subscription that ended 5 days ago + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, + interval: "month", + status: "canceled" + ) + subscription.update!( + created_at: 60.days.ago, + ends_at: 5.days.ago + ) + + churned_mrr = Profitable.churned_mrr(in_the_last: 30.days).to_i + + # Should be full $100, not prorated + assert_equal 10000, churned_mrr, "BUG #6 REGRESSION: Churned MRR should be full rate" + end + + # =========================================================================== + # BUG #7: new_subscribers using wrong date field + # =========================================================================== + # + # ORIGINAL BUG: calculate_new_subscribers was filtering by customer + # created_at instead of subscription created_at. This meant it was + # counting new customers, not new subscriptions. + # + # FIX: Changed to filter by pay_subscriptions.created_at. + # =========================================================================== + + def test_bug7_new_subscribers_uses_subscription_created_at + # Customer created 60 days ago, but subscription created today + @customer.update!(created_at: 60.days.ago) + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + new_subs = Profitable.new_subscribers(in_the_last: 30.days).to_i + + # Should count based on subscription created_at, not customer + assert_equal 1, new_subs, "BUG #7 REGRESSION: Should use subscription.created_at" + end + + def test_bug7_new_subscribers_excludes_old_subscriptions + # Subscription created 60 days ago (outside 30-day window) + old_sub = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + old_sub.update!(created_at: 60.days.ago) + + new_subs = Profitable.new_subscribers(in_the_last: 30.days).to_i + + assert_equal 0, new_subs, "BUG #7 REGRESSION: Should exclude old subscriptions" + end + + # =========================================================================== + # BUG #8: paid_charges not checking object column for charge status + # =========================================================================== + # + # ORIGINAL BUG: The paid_charges method was only checking the `data` + # column for charge status (paid, status). Pay v10+ uses `object` column. + # + # FIX: Updated SQL to use COALESCE to check both object and data columns. + # =========================================================================== + + def test_bug8_paid_charges_works_with_v10_object_column + # Create charge with status in object column (v10+) + create_successful_charge(customer: @customer, amount: 5000) + + revenue = Profitable.all_time_revenue.to_i + + assert_equal 5000, revenue, "BUG #8 REGRESSION: Should read from object column" + end + + def test_bug8_paid_charges_works_with_legacy_data_column + # Create charge with status in data column (pre-v10) + create_successful_charge_legacy(customer: @customer, amount: 3000) + + revenue = Profitable.all_time_revenue.to_i + + assert_equal 3000, revenue, "BUG #8 REGRESSION: Should still read from data column" + end + + def test_bug8_paid_charges_excludes_failed_charges_from_both_columns + # Failed charge with object column + Pay::Charge.create!( + customer: @customer, + processor_id: "ch_failed_v10", + amount: 10000, + currency: "usd", + object: { "paid" => false, "status" => "failed" } + ) + + # Failed charge with data column + Pay::Charge.create!( + customer: @customer, + processor_id: "ch_failed_legacy", + amount: 10000, + currency: "usd", + data: { "paid" => false, "status" => "failed" } + ) + + # Successful charge + create_successful_charge(customer: @customer, amount: 5000) + + revenue = Profitable.all_time_revenue.to_i + + assert_equal 5000, revenue, "BUG #8 REGRESSION: Should only count successful charges" + end + + # =========================================================================== + # BUG #9: Inconsistent return types (float vs integer) + # =========================================================================== + # + # ORIGINAL BUG: normalize_to_monthly was returning floats for some intervals, + # causing MRR and ARR to have inconsistent types (one float, one integer). + # + # FIX: Added .round to the end of normalize_to_monthly to always return + # integer cents. + # =========================================================================== + + def test_bug9_mrr_returns_consistent_integer_type + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 10000, # $100/year = $8.33/month + interval: "year" + ) + + mrr = Profitable.mrr.to_i + + assert_kind_of Integer, mrr, "BUG #9 REGRESSION: MRR should be integer cents" + end + + def test_bug9_arr_returns_consistent_integer_type + create_stripe_subscription_v10( + customer: @customer, + unit_amount: 9900, + interval: "month" + ) + + arr = Profitable.arr.to_i + + assert_kind_of Integer, arr, "BUG #9 REGRESSION: ARR should be integer cents" + end + + def test_bug9_normalize_to_monthly_always_returns_integer + processor = Profitable::Processors::Base.new(@customer) + + # Test various intervals that could produce floats + results = [ + processor.send(:normalize_to_monthly, 10000, "year", 1), # $100/year = $8.33/month + processor.send(:normalize_to_monthly, 7777, "month", 1), # Odd amount + processor.send(:normalize_to_monthly, 10000, "week", 3), # Weekly with count + processor.send(:normalize_to_monthly, 10000, "day", 7) # Daily with count + ] + + results.each do |result| + assert_kind_of Integer, result, "BUG #9 REGRESSION: All normalize results should be integers" + end + end + + # =========================================================================== + # BUG #10: time_to_next_mrr_milestone division by zero + # =========================================================================== + # + # ORIGINAL BUG: When MRR was 0 or growth rate was 0, the milestone + # calculation would fail with a division by zero error. + # + # FIX: Added checks for current_mrr <= 0 and daily_growth_rate <= 0 that + # return informative messages instead of attempting calculation. + # =========================================================================== + + def test_bug10_milestone_handles_zero_mrr + # No subscriptions = 0 MRR + message = Profitable.time_to_next_mrr_milestone + + assert_equal "Unable to calculate. No MRR yet.", message, + "BUG #10 REGRESSION: Should handle zero MRR gracefully" + end + + def test_bug10_milestone_handles_zero_growth + # Create subscription backdated so growth rate is 0 + subscription = create_stripe_subscription_v10( + customer: @customer, + unit_amount: 100000, # $1000/month + interval: "month" + ) + subscription.update!(created_at: 60.days.ago) + + message = Profitable.time_to_next_mrr_milestone + + assert_includes message, "Unable to calculate", + "BUG #10 REGRESSION: Should handle zero/negative growth gracefully" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..35b6c1f --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,676 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "bundler/setup" +require "active_record" +require "active_support/all" +require "action_view" +require "minitest/autorun" +require "minitest/reporters" +require "mocha/minitest" + +# Configure Minitest reporters for better output +Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] + +# Stub Rails module for standalone testing +module Rails + def self.logger + @logger ||= Logger.new(nil) + end + + def self.root + Pathname.new(File.expand_path("..", __dir__)) + end + + def self.env + ActiveSupport::StringInquirer.new("test") + end +end + +# Set up an in-memory SQLite database for testing +ActiveRecord::Base.establish_connection( + adapter: "sqlite3", + database: ":memory:" +) + +# Silence ActiveRecord logs during tests +ActiveRecord::Base.logger = nil + +# Create the schema for Pay models +# Note: Using json instead of jsonb for SQLite compatibility +ActiveRecord::Schema.define do + create_table :pay_customers, force: true do |t| + t.string :owner_type + t.integer :owner_id + t.string :processor + t.string :processor_id + t.boolean :default + t.json :data + t.datetime :deleted_at + t.timestamps + end + + create_table :pay_subscriptions, force: true do |t| + t.references :customer, foreign_key: { to_table: :pay_customers } + t.string :name + t.string :processor_id + t.string :processor_plan + t.integer :quantity, default: 1 + t.string :status + t.datetime :current_period_start + t.datetime :current_period_end + t.datetime :trial_ends_at + t.datetime :ends_at + t.datetime :pause_starts_at + t.datetime :pause_resumes_at + t.json :data + t.json :object # Pay v10+ stores full Stripe objects here + t.text :metadata + t.timestamps + end + + create_table :pay_charges, force: true do |t| + t.references :customer, foreign_key: { to_table: :pay_customers } + t.references :subscription, foreign_key: { to_table: :pay_subscriptions } + t.string :processor_id + t.integer :amount + t.integer :amount_refunded + t.string :currency + t.json :data + t.json :object # Pay v10+ stores full Stripe objects here + t.timestamps + end +end + +# Define minimal Pay models for testing +# (We define our own instead of using the real Pay gem to avoid Rails engine complexity) +module Pay + class Customer < ActiveRecord::Base + self.table_name = "pay_customers" + has_many :subscriptions, class_name: "Pay::Subscription", foreign_key: :customer_id + has_many :charges, class_name: "Pay::Charge", foreign_key: :customer_id + + scope :with_subscriptions, -> { joins(:subscriptions) } + end + + class Subscription < ActiveRecord::Base + self.table_name = "pay_subscriptions" + belongs_to :customer, class_name: "Pay::Customer" + has_many :charges, class_name: "Pay::Charge" + + scope :active, -> { where(status: "active") } + scope :trialing, -> { where(status: "trialing") } + scope :paused, -> { where(status: "paused") } + scope :canceled, -> { where(status: "canceled") } + scope :ended, -> { where(status: "ended") } + end + + class Charge < ActiveRecord::Base + self.table_name = "pay_charges" + belongs_to :customer, class_name: "Pay::Customer" + belongs_to :subscription, class_name: "Pay::Subscription", optional: true + end +end + +# Now require the profitable gem components (skip engine) +require_relative "../lib/profitable/version" +require_relative "../lib/profitable/error" +require_relative "../lib/profitable/mrr_calculator" +require_relative "../lib/profitable/numeric_result" +require_relative "../lib/profitable/json_helpers" + +require "active_support/core_ext/numeric/conversions" + +# Define the Profitable module (mirroring the real implementation) +module Profitable + class << self + include ActionView::Helpers::NumberHelper + include Profitable::JsonHelpers + + DEFAULT_PERIOD = 30.days + 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] + + def mrr + NumericResult.new(MrrCalculator.calculate) + end + + def arr + NumericResult.new(calculate_arr) + end + + def churn(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_churn(in_the_last), :percentage) + end + + def all_time_revenue + NumericResult.new(calculate_all_time_revenue) + end + + def revenue_in_period(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_revenue_in_period(in_the_last)) + end + + def recurring_revenue_in_period(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_recurring_revenue_in_period(in_the_last)) + end + + def recurring_revenue_percentage(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_recurring_revenue_percentage(in_the_last), :percentage) + end + + def estimated_valuation(multiplier = nil, at: nil, multiple: nil) + actual_multiplier = multiplier || at || multiple || 3 + NumericResult.new(calculate_estimated_valuation(actual_multiplier)) + end + + def total_customers + NumericResult.new(calculate_total_customers, :integer) + end + + def total_subscribers + NumericResult.new(calculate_total_subscribers, :integer) + end + + def active_subscribers + NumericResult.new(calculate_active_subscribers, :integer) + end + + def new_customers(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_new_customers(in_the_last), :integer) + end + + def new_subscribers(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_new_subscribers(in_the_last), :integer) + end + + def churned_customers(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_churned_customers(in_the_last), :integer) + end + + def new_mrr(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_new_mrr(in_the_last)) + end + + def churned_mrr(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_churned_mrr(in_the_last)) + end + + def average_revenue_per_customer + NumericResult.new(calculate_average_revenue_per_customer) + end + + def lifetime_value + NumericResult.new(calculate_lifetime_value) + end + + def mrr_growth(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_mrr_growth(in_the_last)) + end + + def mrr_growth_rate(in_the_last: DEFAULT_PERIOD) + NumericResult.new(calculate_mrr_growth_rate(in_the_last), :percentage) + end + + def time_to_next_mrr_milestone + current_mrr = (mrr.to_i) / 100 # Convert cents to dollars + return "Unable to calculate. No MRR yet." if current_mrr <= 0 + + next_milestone = MRR_MILESTONES.find { |milestone| milestone > current_mrr } + return "Congratulations! You've reached the highest milestone." unless next_milestone + + monthly_growth_rate = calculate_mrr_growth_rate / 100 + return "Unable to calculate. Need more data or positive growth." if monthly_growth_rate <= 0 + + # Convert monthly growth rate to daily growth rate + daily_growth_rate = (1 + monthly_growth_rate) ** (1.0 / 30) - 1 + return "Unable to calculate. Growth rate too small." if daily_growth_rate <= 0 + + # Calculate the number of days to reach the next milestone + days_to_milestone = (Math.log(next_milestone.to_f / current_mrr) / Math.log(1 + daily_growth_rate)).ceil + + target_date = Time.current + days_to_milestone.days + + "#{days_to_milestone} days left to $#{number_with_delimiter(next_milestone)} MRR (#{target_date.strftime('%b %d, %Y')})" + end + + private + + def paid_charges + # Pay gem v10+ stores charge data in `object` column, older versions used `data` + # We check both columns for backwards compatibility using database-agnostic JSON extraction + + # Build JSON extraction SQL for both object and data columns + paid_object = json_extract('pay_charges.object', 'paid') + paid_data = json_extract('pay_charges.data', 'paid') + status_object = json_extract('pay_charges.object', 'status') + status_data = json_extract('pay_charges.data', 'status') + + Pay::Charge + .where("pay_charges.amount > 0") + .where(<<~SQL.squish, 'false', 'succeeded') + ( + (COALESCE(#{paid_object}, #{paid_data}) IS NULL + OR COALESCE(#{paid_object}, #{paid_data}) != ?) + ) + AND + ( + COALESCE(#{status_object}, #{status_data}) = ? + OR COALESCE(#{status_object}, #{status_data}) IS NULL + ) + SQL + end + + def calculate_all_time_revenue + paid_charges.sum(:amount) + end + + def calculate_arr + (mrr.to_f * 12).round + end + + def calculate_estimated_valuation(multiplier = 3) + multiplier = parse_multiplier(multiplier) + (calculate_arr * multiplier).round + end + + def parse_multiplier(input) + case input + when Numeric + input.to_f + when String + if input.end_with?('x') + input.chomp('x').to_f + else + input.to_f + end + else + 3.0 # Default multiplier if input is invalid + end.clamp(0.1, 100) # Ensure multiplier is within a reasonable range + end + + def calculate_churn(period = DEFAULT_PERIOD) + start_date = period.ago + + # Count subscribers who were active AT the start of the period + total_subscribers_start = Pay::Subscription + .where('pay_subscriptions.created_at < ?', start_date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', start_date) + .where.not(status: ['trialing', 'paused']) + .distinct + .count('customer_id') + + churned = calculate_churned_customers(period) + return 0 if total_subscribers_start == 0 + (churned.to_f / total_subscribers_start * 100).round(2) + end + + def churned_subscriptions(period = DEFAULT_PERIOD) + Pay::Subscription + .includes(:customer) + .select('pay_subscriptions.*, pay_customers.processor as customer_processor') + .joins(:customer) + .where(status: ['canceled', 'ended']) + .where(ends_at: period.ago..Time.current) + end + + def calculate_churned_customers(period = DEFAULT_PERIOD) + churned_subscriptions(period).distinct.count('customer_id') + end + + def calculate_churned_mrr(period = DEFAULT_PERIOD) + start_date = period.ago + end_date = Time.current + + Pay::Subscription + .includes(:customer) + .select('pay_subscriptions.*, pay_customers.processor as customer_processor') + .joins(:customer) + .where(status: ['canceled', 'ended']) + .where(ends_at: start_date..end_date) + .sum do |subscription| + MrrCalculator.process_subscription(subscription) + end + end + + def calculate_new_mrr(period = DEFAULT_PERIOD) + start_date = period.ago + end_date = Time.current + + Pay::Subscription + .active + .includes(:customer) + .select('pay_subscriptions.*, pay_customers.processor as customer_processor') + .joins(:customer) + .where(created_at: start_date..end_date) + .where.not(status: ['trialing', 'paused']) + .sum do |subscription| + MrrCalculator.process_subscription(subscription) + end + end + + def calculate_revenue_in_period(period) + paid_charges.where(created_at: period.ago..Time.current).sum(:amount) + end + + def calculate_recurring_revenue_in_period(period) + paid_charges + .joins('INNER JOIN pay_subscriptions ON pay_charges.subscription_id = pay_subscriptions.id') + .where(created_at: period.ago..Time.current) + .sum(:amount) + end + + def calculate_recurring_revenue_percentage(period) + total_revenue = calculate_revenue_in_period(period) + recurring_revenue = calculate_recurring_revenue_in_period(period) + + return 0 if total_revenue.zero? + + ((recurring_revenue.to_f / total_revenue) * 100).round(2) + end + + def calculate_total_customers + Pay::Customer.joins(:charges) + .merge(paid_charges) + .distinct + .count + end + + def calculate_total_subscribers + Pay::Customer.joins(:subscriptions).distinct.count + end + + def calculate_active_subscribers + Pay::Customer.joins(:subscriptions) + .where(pay_subscriptions: { status: 'active' }) + .distinct + .count + end + + def actual_customers + Pay::Customer.joins("LEFT JOIN pay_subscriptions ON pay_customers.id = pay_subscriptions.customer_id") + .joins("LEFT JOIN pay_charges ON pay_customers.id = pay_charges.customer_id") + .where("pay_subscriptions.id IS NOT NULL OR pay_charges.amount > 0") + .distinct + end + + def calculate_new_customers(period) + actual_customers.where(created_at: period.ago..Time.current).count + end + + def calculate_new_subscribers(period) + Pay::Customer.joins(:subscriptions) + .where(pay_subscriptions: { created_at: period.ago..Time.current }) + .distinct + .count + end + + def calculate_average_revenue_per_customer + paying_customers = calculate_total_customers + return 0 if paying_customers.zero? + (all_time_revenue.to_f / paying_customers).round + end + + def calculate_lifetime_value + subscribers = calculate_active_subscribers + return 0 if subscribers.zero? + + monthly_arpu = mrr.to_f / subscribers + churn_rate = churn.to_f / 100 + return 0 if churn_rate.zero? + + (monthly_arpu / churn_rate).round + end + + def calculate_mrr_growth(period = DEFAULT_PERIOD) + new_mrr = calculate_new_mrr(period) + churned_mrr = calculate_churned_mrr(period) + new_mrr - churned_mrr + end + + def calculate_mrr_growth_rate(period = DEFAULT_PERIOD) + end_date = Time.current + start_date = end_date - period + + start_mrr = calculate_mrr_at(start_date) + end_mrr = calculate_mrr_at(end_date) + + return 0 if start_mrr == 0 + ((end_mrr.to_f - start_mrr) / start_mrr * 100).round(2) + end + + def calculate_mrr_at(date) + Pay::Subscription + .where('pay_subscriptions.created_at <= ?', date) + .where('pay_subscriptions.ends_at IS NULL OR pay_subscriptions.ends_at > ?', date) + .where('pay_subscriptions.pause_starts_at IS NULL OR pay_subscriptions.pause_starts_at > ?', date) + .where.not(status: ['trialing', 'paused']) + .includes(:customer) + .select('pay_subscriptions.*, pay_customers.processor as customer_processor') + .joins(:customer) + .sum do |subscription| + MrrCalculator.process_subscription(subscription) + end + end + end +end + +# Test helper methods +module ProfitableTestHelpers + # Creates a Stripe subscription with the v10+ object column structure + def create_stripe_subscription_v10(customer:, unit_amount:, interval: "month", interval_count: 1, quantity: 1, status: "active", additional_items: []) + items_data = [ + { + "id" => "si_#{SecureRandom.hex(8)}", + "quantity" => quantity, + "price" => { + "id" => "price_#{SecureRandom.hex(8)}", + "unit_amount" => unit_amount, + "recurring" => { + "interval" => interval, + "interval_count" => interval_count + } + } + } + ] + + additional_items.each do |item| + items_data << { + "id" => "si_#{SecureRandom.hex(8)}", + "quantity" => item[:quantity] || 1, + "price" => { + "id" => "price_#{SecureRandom.hex(8)}", + "unit_amount" => item[:unit_amount], + "recurring" => { + "interval" => item[:interval] || interval, + "interval_count" => item[:interval_count] || interval_count + } + } + } + end + + Pay::Subscription.create!( + customer: customer, + processor_id: "sub_#{SecureRandom.hex(8)}", + name: "default", + quantity: quantity, + status: status, + data: nil, + object: { + "id" => "sub_#{SecureRandom.hex(8)}", + "status" => status, + "items" => { + "data" => items_data + } + } + ) + end + + # Creates a Stripe subscription with the legacy data column structure (pre-v10) + def create_stripe_subscription_legacy(customer:, unit_amount:, interval: "month", interval_count: 1, quantity: 1, status: "active") + Pay::Subscription.create!( + customer: customer, + processor_id: "sub_#{SecureRandom.hex(8)}", + name: "default", + quantity: quantity, + status: status, + object: nil, + data: { + "subscription_items" => [ + { + "id" => "si_#{SecureRandom.hex(8)}", + "quantity" => quantity, + "unit_amount" => unit_amount, + "price" => { + "unit_amount" => unit_amount, + "recurring" => { + "interval" => interval, + "interval_count" => interval_count + } + } + } + ] + } + ) + end + + # Creates a Braintree subscription + def create_braintree_subscription(customer:, price:, interval: "month", interval_count: 1, quantity: 1, status: "active") + Pay::Subscription.create!( + customer: customer, + processor_id: "sub_#{SecureRandom.hex(8)}", + name: "default", + quantity: quantity, + status: status, + object: { + "price" => price, + "billing_period_unit" => interval, + "billing_period_frequency" => interval_count + } + ) + end + + # Creates a Paddle Billing subscription (v10+ structure) + def create_paddle_billing_subscription(customer:, amount:, interval: "month", frequency: 1, quantity: 1, status: "active", additional_items: []) + items_data = [ + { + "quantity" => quantity, + "price" => { + "unit_price" => { "amount" => amount }, + "billing_cycle" => { + "interval" => interval, + "frequency" => frequency + } + } + } + ] + + additional_items.each do |item| + items_data << { + "quantity" => item[:quantity] || 1, + "price" => { + "unit_price" => { "amount" => item[:amount] }, + "billing_cycle" => { + "interval" => item[:interval] || interval, + "frequency" => item[:frequency] || frequency + } + } + } + end + + Pay::Subscription.create!( + customer: customer, + processor_id: "sub_#{SecureRandom.hex(8)}", + name: "default", + quantity: quantity, + status: status, + object: { + "items" => items_data + } + ) + end + + # Creates a Paddle Classic subscription + def create_paddle_classic_subscription(customer:, recurring_price:, interval: "month", quantity: 1, status: "active") + Pay::Subscription.create!( + customer: customer, + processor_id: "sub_#{SecureRandom.hex(8)}", + name: "default", + quantity: quantity, + status: status, + object: { + "recurring_price" => recurring_price, + "recurring_interval" => interval + } + ) + end + + # Creates a Pay customer with a specified processor + def create_customer(processor:) + Pay::Customer.create!( + owner_type: "User", + owner_id: rand(1..10000), + processor: processor, + processor_id: "cus_#{SecureRandom.hex(8)}", + default: true + ) + end + + # Creates a successful charge + def create_successful_charge(customer:, amount:, subscription: nil, created_at: Time.current) + Pay::Charge.create!( + customer: customer, + subscription: subscription, + processor_id: "ch_#{SecureRandom.hex(8)}", + amount: amount, + currency: "usd", + created_at: created_at, + object: { + "paid" => true, + "status" => "succeeded" + } + ) + end + + # Creates a charge with legacy data column + def create_successful_charge_legacy(customer:, amount:, subscription: nil, created_at: Time.current) + Pay::Charge.create!( + customer: customer, + subscription: subscription, + processor_id: "ch_#{SecureRandom.hex(8)}", + amount: amount, + currency: "usd", + created_at: created_at, + object: nil, + data: { + "paid" => true, + "status" => "succeeded" + } + ) + end + + # Creates a failed/refunded charge + def create_failed_charge(customer:, amount:) + Pay::Charge.create!( + customer: customer, + processor_id: "ch_#{SecureRandom.hex(8)}", + amount: amount, + currency: "usd", + object: { + "paid" => false, + "status" => "failed" + } + ) + end +end + +class Minitest::Test + include ProfitableTestHelpers + + def setup + # Clean up database before each test + Pay::Charge.delete_all + Pay::Subscription.delete_all + Pay::Customer.delete_all + end +end