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.
- checksums.yaml +4 -4
- data/.simplecov +47 -0
- data/AGENTS.md +5 -0
- data/Appraisals +50 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +5 -0
- data/README.md +57 -5
- data/Rakefile +10 -1
- data/app/controllers/profitable/dashboard_controller.rb +16 -4
- data/app/views/profitable/dashboard/index.html.erb +151 -18
- data/gemfiles/pay_10.0.gemfile +23 -0
- data/gemfiles/pay_11.0.gemfile +23 -0
- data/gemfiles/pay_7.3.gemfile +23 -0
- data/gemfiles/pay_8.3.gemfile +23 -0
- data/gemfiles/pay_9.0.gemfile +23 -0
- data/gemfiles/rails_7.2.gemfile +23 -0
- data/gemfiles/rails_8.1.gemfile +23 -0
- data/lib/profitable/json_helpers.rb +68 -0
- data/lib/profitable/mrr_calculator.rb +13 -3
- data/lib/profitable/processors/base.rb +28 -5
- data/lib/profitable/processors/braintree_processor.rb +8 -3
- data/lib/profitable/processors/paddle_billing_processor.rb +22 -7
- data/lib/profitable/processors/paddle_classic_processor.rb +7 -2
- data/lib/profitable/processors/stripe_processor.rb +24 -8
- data/lib/profitable/version.rb +1 -1
- data/lib/profitable.rb +282 -75
- metadata +16 -7
|
@@ -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:
|
|
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?
|
|
30
|
+
return 0 if subscription.nil?
|
|
31
|
+
return 0 if subscription_data(subscription).nil?
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
# Get processor from virtual attribute (set by .select() in queries) or from customer association
|
|
34
|
+
processor_name = subscription.try(:customer_processor) || subscription.customer&.processor
|
|
35
|
+
|
|
36
|
+
processor_class = processor_for(processor_name)
|
|
33
37
|
mrr = processor_class.new(subscription).calculate_mrr
|
|
34
38
|
|
|
35
39
|
# Ensure MRR is a non-negative number
|
|
@@ -39,6 +43,12 @@ module Profitable
|
|
|
39
43
|
0
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# Pay gem v10+ stores Stripe objects in the `object` column,
|
|
47
|
+
# while older versions used `data`. This method provides backwards compatibility.
|
|
48
|
+
def self.subscription_data(subscription)
|
|
49
|
+
subscription.try(:object) || subscription.try(:data)
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
def self.processor_for(processor_name)
|
|
43
53
|
case processor_name
|
|
44
54
|
when 'stripe'
|
|
@@ -13,22 +13,45 @@ module Profitable
|
|
|
13
13
|
|
|
14
14
|
protected
|
|
15
15
|
|
|
16
|
+
# Pay gem v10+ stores Stripe objects in the `object` column,
|
|
17
|
+
# while older versions used `data`. This method provides backwards compatibility.
|
|
18
|
+
def subscription_data
|
|
19
|
+
subscription.try(:object) || subscription.try(:data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Converts a billing amount to its monthly equivalent rate.
|
|
23
|
+
#
|
|
24
|
+
# Uses floating-point arithmetic for precision during calculation,
|
|
25
|
+
# then rounds to the nearest integer cent at the end. This approach
|
|
26
|
+
# ensures accurate rounding for fractional results (e.g., $100/year = $8.33/month = 833 cents).
|
|
27
|
+
#
|
|
28
|
+
# @param amount [Integer, String] The billing amount in cents
|
|
29
|
+
# @param interval [String] The billing interval ('day', 'week', 'month', 'year')
|
|
30
|
+
# @param interval_count [Integer, String] How many intervals per billing cycle
|
|
31
|
+
# @return [Integer] The monthly amount in cents (always a non-negative integer)
|
|
16
32
|
def normalize_to_monthly(amount, interval, interval_count)
|
|
17
33
|
return 0 if amount.nil? || interval.nil? || interval_count.nil?
|
|
18
34
|
|
|
19
|
-
|
|
35
|
+
# Ensure interval_count is converted to integer before division
|
|
36
|
+
interval_count_int = interval_count.to_i
|
|
37
|
+
return 0 if interval_count_int.zero?
|
|
38
|
+
|
|
39
|
+
# Calculate using floats for precision, round at the end for integer cents
|
|
40
|
+
monthly_amount = case interval.to_s.downcase
|
|
20
41
|
when 'day'
|
|
21
|
-
amount * 30
|
|
42
|
+
amount.to_f * 30 / interval_count_int
|
|
22
43
|
when 'week'
|
|
23
|
-
amount * 4
|
|
44
|
+
amount.to_f * 4 / interval_count_int
|
|
24
45
|
when 'month'
|
|
25
|
-
amount /
|
|
46
|
+
amount.to_f / interval_count_int
|
|
26
47
|
when 'year'
|
|
27
|
-
amount / (12
|
|
48
|
+
amount.to_f / (12 * interval_count_int)
|
|
28
49
|
else
|
|
29
50
|
Rails.logger.warn("Unknown interval for MRR calculation: #{interval}")
|
|
30
51
|
0
|
|
31
52
|
end
|
|
53
|
+
|
|
54
|
+
monthly_amount.round # Return integer cents
|
|
32
55
|
end
|
|
33
56
|
end
|
|
34
57
|
end
|
|
@@ -2,10 +2,15 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class BraintreeProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
|
+
|
|
8
|
+
amount = data['price']
|
|
9
|
+
return 0 if amount.nil?
|
|
10
|
+
|
|
6
11
|
quantity = subscription.quantity || 1
|
|
7
|
-
interval =
|
|
8
|
-
interval_count =
|
|
12
|
+
interval = data['billing_period_unit']
|
|
13
|
+
interval_count = data['billing_period_frequency'] || 1
|
|
9
14
|
|
|
10
15
|
normalize_to_monthly(amount * quantity, interval, interval_count)
|
|
11
16
|
end
|
|
@@ -2,15 +2,30 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class PaddleBillingProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
6
|
-
return 0 if
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
interval = price_data['billing_cycle']['interval']
|
|
11
|
-
interval_count = price_data['billing_cycle']['frequency']
|
|
8
|
+
items = data['items']
|
|
9
|
+
return 0 if items.nil? || items.empty?
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
# Sum MRR from ALL subscription items
|
|
12
|
+
total_mrr = 0
|
|
13
|
+
|
|
14
|
+
items.each do |item|
|
|
15
|
+
price_data = item['price']
|
|
16
|
+
next if price_data.nil?
|
|
17
|
+
|
|
18
|
+
amount = price_data.dig('unit_price', 'amount')
|
|
19
|
+
next if amount.nil?
|
|
20
|
+
|
|
21
|
+
item_quantity = item['quantity'] || 1
|
|
22
|
+
interval = price_data.dig('billing_cycle', 'interval')
|
|
23
|
+
interval_count = price_data.dig('billing_cycle', 'frequency')
|
|
24
|
+
|
|
25
|
+
total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
total_mrr
|
|
14
29
|
end
|
|
15
30
|
end
|
|
16
31
|
end
|
|
@@ -2,9 +2,14 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class PaddleClassicProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
|
+
|
|
8
|
+
amount = data['recurring_price']
|
|
9
|
+
return 0 if amount.nil?
|
|
10
|
+
|
|
6
11
|
quantity = subscription.quantity || 1
|
|
7
|
-
interval =
|
|
12
|
+
interval = data['recurring_interval']
|
|
8
13
|
interval_count = 1 # Paddle Classic doesn't have interval_count
|
|
9
14
|
|
|
10
15
|
normalize_to_monthly(amount * quantity, interval, interval_count)
|
|
@@ -2,18 +2,34 @@ module Profitable
|
|
|
2
2
|
module Processors
|
|
3
3
|
class StripeProcessor < Base
|
|
4
4
|
def calculate_mrr
|
|
5
|
-
|
|
5
|
+
data = subscription_data
|
|
6
|
+
return 0 if data.nil?
|
|
7
|
+
|
|
8
|
+
# Pay gem v10+ stores items at object['items']['data']
|
|
9
|
+
# Older versions stored at data['subscription_items']
|
|
10
|
+
subscription_items = data.dig('items', 'data') || data['subscription_items']
|
|
6
11
|
return 0 if subscription_items.nil? || subscription_items.empty?
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
# Sum MRR from ALL subscription items (not just the first one)
|
|
14
|
+
# Stripe subscriptions can have multiple line items
|
|
15
|
+
total_mrr = 0
|
|
16
|
+
|
|
17
|
+
subscription_items.each do |item|
|
|
18
|
+
price_data = item['price'] || item
|
|
19
|
+
next if price_data.nil?
|
|
20
|
+
|
|
21
|
+
amount = price_data['unit_amount']
|
|
22
|
+
next if amount.nil?
|
|
23
|
+
|
|
24
|
+
# Each item can have its own quantity
|
|
25
|
+
item_quantity = item['quantity'] || 1
|
|
26
|
+
interval = price_data.dig('recurring', 'interval')
|
|
27
|
+
interval_count = price_data.dig('recurring', 'interval_count') || 1
|
|
10
28
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
interval = price_data.dig('recurring', 'interval')
|
|
14
|
-
interval_count = price_data.dig('recurring', 'interval_count') || 1
|
|
29
|
+
total_mrr += normalize_to_monthly(amount * item_quantity, interval, interval_count)
|
|
30
|
+
end
|
|
15
31
|
|
|
16
|
-
|
|
32
|
+
total_mrr
|
|
17
33
|
end
|
|
18
34
|
end
|
|
19
35
|
end
|
data/lib/profitable/version.rb
CHANGED