profitable 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profitable
4
+ module JsonHelpers
5
+ # Regex patterns for validating SQL identifiers to prevent SQL injection
6
+ # Only allows: alphanumeric characters, underscores, and dots (for table.column format)
7
+ VALID_TABLE_COLUMN_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
8
+ VALID_JSON_KEY_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
9
+
10
+ # Returns the appropriate JSON extraction syntax for the current database adapter
11
+ # Supports PostgreSQL, MySQL (5.7.9+), and SQLite
12
+ #
13
+ # @param table_column [String] The table and column name (e.g., 'pay_charges.object')
14
+ # @param json_key [String] The JSON key to extract (e.g., 'paid', 'status')
15
+ # @return [String] Database-specific SQL for JSON extraction
16
+ # @raise [ArgumentError] if table_column or json_key contain invalid characters
17
+ #
18
+ # @example PostgreSQL
19
+ # json_extract('pay_charges.object', 'paid')
20
+ # # => "pay_charges.object ->> 'paid'"
21
+ #
22
+ # @example MySQL
23
+ # json_extract('pay_charges.object', 'paid')
24
+ # # => "JSON_UNQUOTE(JSON_EXTRACT(pay_charges.object, '$.paid'))"
25
+ #
26
+ # @example SQLite
27
+ # json_extract('pay_charges.object', 'paid')
28
+ # # => "json_extract(pay_charges.object, '$.paid')"
29
+ def json_extract(table_column, json_key)
30
+ # Validate inputs to prevent SQL injection
31
+ validate_table_column!(table_column)
32
+ validate_json_key!(json_key)
33
+
34
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
35
+
36
+ case adapter
37
+ when /postgres/
38
+ "#{table_column} ->> '#{json_key}'"
39
+ when /mysql/, /trilogy/
40
+ # MySQL 5.7.9+ supports JSON_EXTRACT and ->> operator
41
+ # We use JSON_UNQUOTE(JSON_EXTRACT()) for maximum compatibility
42
+ "JSON_UNQUOTE(JSON_EXTRACT(#{table_column}, '$.#{json_key}'))"
43
+ when /sqlite/
44
+ "json_extract(#{table_column}, '$.#{json_key}')"
45
+ else
46
+ # Fallback to PostgreSQL syntax for unknown adapters
47
+ Rails.logger.warn("Unknown database adapter '#{adapter}' for JSON extraction. Falling back to PostgreSQL syntax.")
48
+ "#{table_column} ->> '#{json_key}'"
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def validate_table_column!(table_column)
55
+ unless table_column.is_a?(String) && table_column.match?(VALID_TABLE_COLUMN_PATTERN)
56
+ raise ArgumentError, "Invalid table_column format: #{table_column.inspect}. " \
57
+ "Must be alphanumeric with underscores/dots only (e.g., 'pay_charges.object')."
58
+ end
59
+ end
60
+
61
+ def validate_json_key!(json_key)
62
+ unless json_key.is_a?(String) && json_key.match?(VALID_JSON_KEY_PATTERN)
63
+ raise ArgumentError, "Invalid json_key format: #{json_key.inspect}. " \
64
+ "Must be alphanumeric with underscores only (e.g., 'paid', 'status')."
65
+ end
66
+ end
67
+ end
68
+ end
@@ -10,7 +10,7 @@ module Profitable
10
10
  total_mrr = 0
11
11
  subscriptions = Pay::Subscription
12
12
  .active
13
- .where.not(status: ['trialing', 'paused'])
13
+ .where.not(status: Profitable::EXCLUDED_STATUSES)
14
14
  .includes(:customer)
15
15
  .select('pay_subscriptions.*, pay_customers.processor as customer_processor')
16
16
  .joins(:customer)
@@ -27,9 +27,13 @@ module Profitable
27
27
  end
28
28
 
29
29
  def self.process_subscription(subscription)
30
- return 0 if subscription.nil? || subscription.data.nil?
30
+ return 0 if subscription.nil?
31
+ return 0 if subscription_data(subscription).nil?
31
32
 
32
- processor_class = processor_for(subscription.customer_processor)
33
+ # Get processor from virtual attribute (set by .select() in queries) or from customer association
34
+ processor_name = subscription.try(:customer_processor) || subscription.customer&.processor
35
+
36
+ processor_class = processor_for(processor_name)
33
37
  mrr = processor_class.new(subscription).calculate_mrr
34
38
 
35
39
  # Ensure MRR is a non-negative number
@@ -39,6 +43,12 @@ module Profitable
39
43
  0
40
44
  end
41
45
 
46
+ # Pay gem v10+ stores Stripe objects in the `object` column,
47
+ # while older versions used `data`. This method provides backwards compatibility.
48
+ def self.subscription_data(subscription)
49
+ subscription.try(:object) || subscription.try(:data)
50
+ end
51
+
42
52
  def self.processor_for(processor_name)
43
53
  case processor_name
44
54
  when 'stripe'
@@ -13,22 +13,45 @@ module Profitable
13
13
 
14
14
  protected
15
15
 
16
+ # Pay gem v10+ stores Stripe objects in the `object` column,
17
+ # while older versions used `data`. This method provides backwards compatibility.
18
+ def subscription_data
19
+ subscription.try(:object) || subscription.try(:data)
20
+ end
21
+
22
+ # Converts a billing amount to its monthly equivalent rate.
23
+ #
24
+ # Uses floating-point arithmetic for precision during calculation,
25
+ # then rounds to the nearest integer cent at the end. This approach
26
+ # ensures accurate rounding for fractional results (e.g., $100/year = $8.33/month = 833 cents).
27
+ #
28
+ # @param amount [Integer, String] The billing amount in cents
29
+ # @param interval [String] The billing interval ('day', 'week', 'month', 'year')
30
+ # @param interval_count [Integer, String] How many intervals per billing cycle
31
+ # @return [Integer] The monthly amount in cents (always a non-negative integer)
16
32
  def normalize_to_monthly(amount, interval, interval_count)
17
33
  return 0 if amount.nil? || interval.nil? || interval_count.nil?
18
34
 
19
- case interval.to_s.downcase
35
+ # Ensure interval_count is converted to integer before division
36
+ interval_count_int = interval_count.to_i
37
+ return 0 if interval_count_int.zero?
38
+
39
+ # Calculate using floats for precision, round at the end for integer cents
40
+ monthly_amount = case interval.to_s.downcase
20
41
  when 'day'
21
- amount * 30.0 / interval_count
42
+ amount.to_f * 30 / interval_count_int
22
43
  when 'week'
23
- amount * 4.0 / interval_count
44
+ amount.to_f * 4 / interval_count_int
24
45
  when 'month'
25
- amount / interval_count
46
+ amount.to_f / interval_count_int
26
47
  when 'year'
27
- amount / (12.0 * interval_count)
48
+ amount.to_f / (12 * interval_count_int)
28
49
  else
29
50
  Rails.logger.warn("Unknown interval for MRR calculation: #{interval}")
30
51
  0
31
52
  end
53
+
54
+ monthly_amount.round # Return integer cents
32
55
  end
33
56
  end
34
57
  end
@@ -2,10 +2,15 @@ module Profitable
2
2
  module Processors
3
3
  class BraintreeProcessor < Base
4
4
  def calculate_mrr
5
- amount = subscription.data['price']
5
+ data = subscription_data
6
+ return 0 if data.nil?
7
+
8
+ amount = data['price']
9
+ return 0 if amount.nil?
10
+
6
11
  quantity = subscription.quantity || 1
7
- interval = subscription.data['billing_period_unit']
8
- interval_count = subscription.data['billing_period_frequency'] || 1
12
+ interval = data['billing_period_unit']
13
+ interval_count = data['billing_period_frequency'] || 1
9
14
 
10
15
  normalize_to_monthly(amount * quantity, interval, interval_count)
11
16
  end
@@ -2,15 +2,30 @@ module Profitable
2
2
  module Processors
3
3
  class PaddleBillingProcessor < Base
4
4
  def calculate_mrr
5
- price_data = subscription.data['items']&.first&.dig('price')
6
- return 0 if price_data.nil?
5
+ data = subscription_data
6
+ return 0 if data.nil?
7
7
 
8
- amount = price_data['unit_price']['amount']
9
- quantity = subscription.quantity || 1
10
- interval = price_data['billing_cycle']['interval']
11
- interval_count = price_data['billing_cycle']['frequency']
8
+ items = data['items']
9
+ return 0 if items.nil? || items.empty?
12
10
 
13
- normalize_to_monthly(amount * quantity, interval, interval_count)
11
+ # Sum MRR from ALL subscription items
12
+ total_mrr = 0
13
+
14
+ items.each do |item|
15
+ price_data = item['price']
16
+ next if price_data.nil?
17
+
18
+ amount = price_data.dig('unit_price', 'amount')
19
+ next if amount.nil?
20
+
21
+ item_quantity = item['quantity'] || 1
22
+ interval = price_data.dig('billing_cycle', 'interval')
23
+ interval_count = price_data.dig('billing_cycle', 'frequency')
24
+
25
+ total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
26
+ end
27
+
28
+ total_mrr
14
29
  end
15
30
  end
16
31
  end
@@ -2,9 +2,14 @@ module Profitable
2
2
  module Processors
3
3
  class PaddleClassicProcessor < Base
4
4
  def calculate_mrr
5
- amount = subscription.data['recurring_price']
5
+ data = subscription_data
6
+ return 0 if data.nil?
7
+
8
+ amount = data['recurring_price']
9
+ return 0 if amount.nil?
10
+
6
11
  quantity = subscription.quantity || 1
7
- interval = subscription.data['recurring_interval']
12
+ interval = data['recurring_interval']
8
13
  interval_count = 1 # Paddle Classic doesn't have interval_count
9
14
 
10
15
  normalize_to_monthly(amount * quantity, interval, interval_count)
@@ -2,18 +2,34 @@ module Profitable
2
2
  module Processors
3
3
  class StripeProcessor < Base
4
4
  def calculate_mrr
5
- subscription_items = subscription.data['subscription_items']
5
+ data = subscription_data
6
+ return 0 if data.nil?
7
+
8
+ # Pay gem v10+ stores items at object['items']['data']
9
+ # Older versions stored at data['subscription_items']
10
+ subscription_items = data.dig('items', 'data') || data['subscription_items']
6
11
  return 0 if subscription_items.nil? || subscription_items.empty?
7
12
 
8
- price_data = subscription_items[0]['price']
9
- return 0 if price_data.nil?
13
+ # Sum MRR from ALL subscription items (not just the first one)
14
+ # Stripe subscriptions can have multiple line items
15
+ total_mrr = 0
16
+
17
+ subscription_items.each do |item|
18
+ price_data = item['price'] || item
19
+ next if price_data.nil?
20
+
21
+ amount = price_data['unit_amount']
22
+ next if amount.nil?
23
+
24
+ # Each item can have its own quantity
25
+ item_quantity = item['quantity'] || 1
26
+ interval = price_data.dig('recurring', 'interval')
27
+ interval_count = price_data.dig('recurring', 'interval_count') || 1
10
28
 
11
- amount = price_data['unit_amount']
12
- quantity = subscription.quantity || 1
13
- interval = price_data.dig('recurring', 'interval')
14
- interval_count = price_data.dig('recurring', 'interval_count') || 1
29
+ total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
30
+ end
15
31
 
16
- normalize_to_monthly(amount * quantity, interval, interval_count)
32
+ total_mrr
17
33
  end
18
34
  end
19
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profitable
4
- VERSION = "0.2.3"
4
+ VERSION = "0.4.0"
5
5
  end