usage_credits 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +559 -0
  6. data/Rakefile +32 -0
  7. data/lib/generators/usage_credits/install_generator.rb +49 -0
  8. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
  9. data/lib/generators/usage_credits/templates/initializer.rb +105 -0
  10. data/lib/usage_credits/configuration.rb +204 -0
  11. data/lib/usage_credits/core_ext/numeric.rb +59 -0
  12. data/lib/usage_credits/cost/base.rb +43 -0
  13. data/lib/usage_credits/cost/compound.rb +37 -0
  14. data/lib/usage_credits/cost/fixed.rb +34 -0
  15. data/lib/usage_credits/cost/variable.rb +42 -0
  16. data/lib/usage_credits/engine.rb +37 -0
  17. data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
  18. data/lib/usage_credits/helpers/credits_helper.rb +45 -0
  19. data/lib/usage_credits/helpers/period_parser.rb +77 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
  21. data/lib/usage_credits/models/allocation.rb +31 -0
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
  25. data/lib/usage_credits/models/credit_pack.rb +159 -0
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
  27. data/lib/usage_credits/models/fulfillment.rb +91 -0
  28. data/lib/usage_credits/models/operation.rb +153 -0
  29. data/lib/usage_credits/models/transaction.rb +174 -0
  30. data/lib/usage_credits/models/wallet.rb +310 -0
  31. data/lib/usage_credits/railtie.rb +17 -0
  32. data/lib/usage_credits/services/fulfillment_service.rb +129 -0
  33. data/lib/usage_credits/version.rb +5 -0
  34. data/lib/usage_credits.rb +170 -0
  35. data/sig/usagecredits.rbs +4 -0
  36. metadata +115 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ create_table :usage_credits_wallets, id: primary_key_type do |t|
8
+ t.references :owner, polymorphic: true, null: false, type: foreign_key_type
9
+ t.integer :balance, null: false, default: 0
10
+ t.send(json_column_type, :metadata, null: false, default: {})
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ create_table :usage_credits_transactions, id: primary_key_type do |t|
16
+ t.references :wallet, null: false, type: foreign_key_type
17
+ t.integer :amount, null: false
18
+ t.string :category, null: false
19
+ t.datetime :expires_at
20
+ t.references :fulfillment, type: foreign_key_type
21
+ t.send(json_column_type, :metadata, null: false, default: {})
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ create_table :usage_credits_fulfillments, id: primary_key_type do |t|
27
+ t.references :wallet, null: false, type: foreign_key_type
28
+ t.references :source, polymorphic: true, type: foreign_key_type
29
+ t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment
30
+ t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription)
31
+ t.datetime :last_fulfilled_at # When last fulfilled
32
+ t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
33
+ t.string :fulfillment_period # "2.months", "15.days", etc. (nil for one-time)
34
+ t.datetime :stops_at # When to stop performing fulfillments
35
+ t.send(json_column_type, :metadata, null: false, default: {})
36
+
37
+ t.timestamps
38
+ end
39
+
40
+ # Allocations are the basis for the bucket-based, FIFO with expiration inventory-like system
41
+ create_table :usage_credits_allocations, id: primary_key_type do |t|
42
+ # The "spend" transaction (negative) that is *using* credits
43
+ t.references :transaction, null: false, type: foreign_key_type,
44
+ foreign_key: { to_table: :usage_credits_transactions },
45
+ index: { name: "index_allocations_on_transaction_id" }
46
+
47
+ # The "source" transaction (positive) from which the credits are drawn
48
+ t.references :source_transaction, null: false, type: foreign_key_type,
49
+ foreign_key: { to_table: :usage_credits_transactions },
50
+ index: { name: "index_allocations_on_source_transaction_id" }
51
+
52
+ # How many credits were allocated from that particular source
53
+ t.integer :amount, null: false
54
+
55
+ t.timestamps
56
+ end
57
+
58
+ # Add indexes
59
+ add_index :usage_credits_transactions, :category
60
+ add_index :usage_credits_transactions, :expires_at
61
+
62
+ # Composite index on (expires_at, id) for efficient ordering when calculating balances
63
+ add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id'
64
+
65
+ # Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts
66
+ add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount'
67
+
68
+ add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx"
69
+
70
+ add_index :usage_credits_fulfillments, :next_fulfillment_at
71
+ add_index :usage_credits_fulfillments, :fulfillment_type
72
+ end
73
+
74
+ private
75
+
76
+ def primary_and_foreign_key_types
77
+ config = Rails.configuration.generators
78
+ setting = config.options[config.orm][:primary_key_type]
79
+ primary_key_type = setting || :primary_key
80
+ foreign_key_type = setting || :bigint
81
+ [primary_key_type, foreign_key_type]
82
+ end
83
+
84
+ def json_column_type
85
+ return :jsonb if connection.adapter_name.downcase.include?('postgresql')
86
+ :json
87
+ end
88
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ UsageCredits.configure do |config|
4
+ #
5
+ # Define your credit-consuming operations below
6
+ #
7
+ # Example:
8
+ #
9
+ # operation :send_email do
10
+ # costs 1.credit
11
+ # end
12
+ #
13
+ # operation :process_image do
14
+ # costs 10.credits + 1.credit_per(:mb)
15
+ # validate ->(params) { params[:size] <= 100.megabytes }, "File too large"
16
+ # end
17
+ #
18
+ # operation :generate_ai_response do
19
+ # costs 5.credits
20
+ # validate ->(params) { params[:prompt].length <= 1000 }, "Prompt too long"
21
+ # meta category: :ai, description: "Generate AI response"
22
+ # end
23
+ #
24
+ # operation :process_items do
25
+ # costs 1.credit_per(:units) # Cost per item processed
26
+ # meta category: :batch, description: "Process items in batch"
27
+ # end
28
+ #
29
+ #
30
+ #
31
+ # Example credit packs (uncomment and modify as needed):
32
+ #
33
+ # credit_pack :tiny do
34
+ # gives 100.credits
35
+ # costs 99.cents # Price can be in cents or dollars
36
+ # end
37
+ #
38
+ # credit_pack :starter do
39
+ # gives 1000.credits
40
+ # bonus 100.credits # Optional bonus credits
41
+ # costs 49.dollars
42
+ # end
43
+ #
44
+ # credit_pack :pro do
45
+ # gives 5000.credits
46
+ # bonus 1000.credits
47
+ # costs 199.dollars
48
+ # end
49
+ #
50
+ #
51
+ #
52
+ # Example subscription plans (uncomment and modify as needed):
53
+ #
54
+ # subscription_plan :basic do
55
+ # gives 1000.credits.every(:month)
56
+ # signup_bonus 100.credits
57
+ # unused_credits :expire # Credits reset each month
58
+ # end
59
+ #
60
+ # subscription_plan :pro do
61
+ # gives 10_000.credits.every(:month)
62
+ # signup_bonus 1_000.credits
63
+ # trial_includes 500.credits
64
+ # unused_credits :expire # Credits expire at the end of the fulfillment period (use :rollover to roll over to next period)
65
+ # end
66
+ #
67
+ #
68
+ #
69
+ # Alert when balance drops below this threshold (default: 100 credits)
70
+ # Set to nil to disable low balance alerts
71
+ #
72
+ # config.low_balance_threshold = 100.credits
73
+ #
74
+ #
75
+ # Handle low credit balance alerts – Useful to sell booster credit packs, for example
76
+ #
77
+ # config.on_low_balance do |user|
78
+ # Send notification to user when their balance drops below the threshold
79
+ # UserMailer.low_credits_alert(user).deliver_later
80
+ # end
81
+ #
82
+ #
83
+ #
84
+ # For how long expiring credits from the previous fulfillment cycle will "overlap" the following fulfillment period.
85
+ # During this time, old credits from the previous period will erroneously count as available balance.
86
+ # But if we set this to 0 or nil, user balance will show up as zero some time until the next fulfillment cycle hits.
87
+ # A good default is to match the frequency of your UsageCredits::FulfillmentJob
88
+ # fulfillment_grace_period = 5.minutes
89
+ #
90
+ #
91
+ #
92
+ # Rounding strategy for credit calculations (default: :ceil)
93
+ # :ceil - Always round up (2.1 => 3)
94
+ # :floor - Always round down (2.9 => 2)
95
+ # :round - Standard rounding (2.4 => 2, 2.6 => 3)
96
+ #
97
+ # config.rounding_strategy = :ceil
98
+ #
99
+ #
100
+ # Format credits for display (default: "X credits")
101
+ #
102
+ # config.format_credits do |amount|
103
+ # "#{amount} credits"
104
+ # end
105
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Configuration for the UsageCredits gem. This is the single source of truth for all settings.
5
+ # This is what turns what's defined in the initializer DSL into actual objects we can use and operate with.
6
+ class Configuration
7
+ VALID_ROUNDING_STRATEGIES = [:ceil, :floor, :round].freeze
8
+ VALID_CURRENCIES = [:usd, :eur, :gbp, :sgd, :chf].freeze
9
+
10
+ # =========================================
11
+ # Core Data Stores
12
+ # =========================================
13
+
14
+ # Stores all the things users can do with credits
15
+ attr_reader :operations
16
+
17
+ # Stores all the things users can buy or subscribe to
18
+ attr_reader :credit_packs
19
+ attr_reader :credit_subscription_plans
20
+
21
+ # =========================================
22
+ # Basic Settings
23
+ # =========================================
24
+
25
+ attr_reader :default_currency
26
+
27
+ attr_reader :rounding_strategy
28
+
29
+ attr_reader :credit_formatter
30
+
31
+ attr_accessor :fulfillment_grace_period
32
+
33
+ # =========================================
34
+ # Low balance
35
+ # =========================================
36
+
37
+ attr_accessor :allow_negative_balance
38
+
39
+ attr_reader :low_balance_threshold
40
+
41
+ attr_reader :low_balance_callback
42
+
43
+ def initialize
44
+ # Initialize empty data stores
45
+ @operations = {} # Credit-consuming operations (e.g., "send_email: 1 credit")
46
+ @credit_packs = {} # One-time purchases (e.g., "100 credits for $49")
47
+ @credit_subscription_plans = {} # Recurring plans (e.g., "1000 credits/month for $99")
48
+
49
+ # Set sensible defaults
50
+ @default_currency = :usd
51
+ @rounding_strategy = :ceil # Always round up to ensure we never undercharge
52
+ @credit_formatter = ->(amount) { "#{amount} credits" } # How to format credit amounts in the UI
53
+
54
+ # Grace period for credit expiration after fulfillment period ends.
55
+ # For how long will expiring credits "overlap" the following fulfillment period.
56
+ # This ensures smooth transition between fulfillment periods.
57
+ # For this amount of time, old, already expired credits will be erroneously counted as available in the user's balance.
58
+ # Keep it short enough that users don't notice they have the last period's credits still available, but
59
+ # long enough that there's a smooth transition and users never get zero credits in between fullfillment periods
60
+ # A good setting is to match the frequency of your UsageCredits::FulfillmentJob runs
61
+ @fulfillment_grace_period = 5.minutes # If you run your fulfillment job every 5 minutes, this should be enough
62
+
63
+ @allow_negative_balance = false
64
+ @low_balance_threshold = nil
65
+ @low_balance_callback = nil # Called when user hits low_balance_threshold
66
+ end
67
+
68
+ # =========================================
69
+ # DSL Methods for Defining Things
70
+ # =========================================
71
+
72
+ # Define a credit-consuming operation
73
+ def operation(name, &block)
74
+ raise ArgumentError, "Block is required for operation definition" unless block_given?
75
+ operation = Operation.new(name)
76
+ operation.instance_eval(&block)
77
+ @operations[name.to_sym] = operation
78
+ operation
79
+ end
80
+
81
+ # Define a one-time purchase credit pack
82
+ def credit_pack(name, &block)
83
+ raise ArgumentError, "Block is required for credit pack definition" unless block_given?
84
+ raise ArgumentError, "Credit pack name can't be blank" if name.blank?
85
+
86
+ name = name.to_sym
87
+ pack = CreditPack.new(name)
88
+ pack.instance_eval(&block)
89
+ pack.validate!
90
+ @credit_packs[name] = pack
91
+ end
92
+
93
+ # Define a recurring subscription plan
94
+ def subscription_plan(name, &block)
95
+ raise ArgumentError, "Block is required for subscription plan definition" unless block_given?
96
+ raise ArgumentError, "Subscription plan name can't be blank" if name.blank?
97
+
98
+ name = name.to_sym
99
+ plan = CreditSubscriptionPlan.new(name)
100
+ plan.instance_eval(&block)
101
+ plan.validate!
102
+ @credit_subscription_plans[name] = plan
103
+ end
104
+
105
+ # Find a subscription plan by its processor-specific ID
106
+ def find_subscription_plan_by_processor_id(processor_id)
107
+ @credit_subscription_plans.values.find do |plan|
108
+ plan.processor_plan_ids.values.include?(processor_id)
109
+ end
110
+ end
111
+
112
+ # =========================================
113
+ # Configuration Setters
114
+ # =========================================
115
+
116
+ # Set default currency with validation
117
+ def default_currency=(value)
118
+ value = value.to_s.downcase.to_sym
119
+ unless VALID_CURRENCIES.include?(value)
120
+ raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}"
121
+ end
122
+ @default_currency = value
123
+ end
124
+
125
+ # Set low balance threshold with validation
126
+ def low_balance_threshold=(value)
127
+ if value
128
+ value = value.to_i
129
+ raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative?
130
+ end
131
+ @low_balance_threshold = value
132
+ end
133
+
134
+ # Set rounding strategy with validation
135
+ def rounding_strategy=(strategy)
136
+ strategy = strategy.to_sym if strategy.respond_to?(:to_sym)
137
+ unless VALID_ROUNDING_STRATEGIES.include?(strategy)
138
+ strategy = :ceil # Default to ceiling if invalid
139
+ end
140
+ @rounding_strategy = strategy
141
+ end
142
+
143
+ def fulfillment_grace_period=(value)
144
+ if value.nil? || value&.to_i == 0
145
+ @fulfillment_grace_period = 1.second
146
+ return
147
+ end
148
+
149
+ unless value.is_a?(ActiveSupport::Duration)
150
+ raise ArgumentError, "Fulfillment grace period must be an ActiveSupport::Duration (e.g. 1.day, 7.minutes)"
151
+ end
152
+
153
+ @fulfillment_grace_period = value
154
+ end
155
+
156
+ # =========================================
157
+ # Callback & Formatter Configuration
158
+ # =========================================
159
+
160
+ # Set how credits are displayed in the UI
161
+ def format_credits(&block)
162
+ @credit_formatter = block
163
+ end
164
+
165
+ # Set what happens when credits are low
166
+ def on_low_balance(&block)
167
+ raise ArgumentError, "Block is required for low balance callback" unless block_given?
168
+ @low_balance_callback = block
169
+ end
170
+
171
+ # =========================================
172
+ # Validation
173
+ # =========================================
174
+
175
+ # Ensure configuration is valid
176
+ def validate!
177
+ validate_currency!
178
+ validate_threshold!
179
+ validate_rounding_strategy!
180
+ true
181
+ end
182
+
183
+ private
184
+
185
+ def validate_currency!
186
+ raise ArgumentError, "Default currency can't be blank" if default_currency.blank?
187
+ unless VALID_CURRENCIES.include?(default_currency.to_s.downcase.to_sym)
188
+ raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}"
189
+ end
190
+ end
191
+
192
+ def validate_threshold!
193
+ if @low_balance_threshold && @low_balance_threshold.negative?
194
+ raise ArgumentError, "Low balance threshold must be greater than or equal to zero"
195
+ end
196
+ end
197
+
198
+ def validate_rounding_strategy!
199
+ unless VALID_ROUNDING_STRATEGIES.include?(@rounding_strategy)
200
+ raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(', ')}"
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric"
4
+
5
+ # This is what allows us to write things like `3.credits` in the DSL
6
+ # Then the actual cost gets calculated in the UsageCredits::Cost classes
7
+ # (Cost::Base, Cost::Fixed, Cost::Variable, Cost::Compound, etc.)
8
+ class Numeric
9
+ def credits
10
+ raise ArgumentError, "Credit amount must be a whole number (decimals are not allowed)" unless self == self.to_i
11
+ raise ArgumentError, "Credit amount cannot be negative" if self.negative?
12
+ UsageCredits::Cost::Fixed.new(self.to_i)
13
+ end
14
+ alias_method :credit, :credits
15
+
16
+ def credits_per(unit)
17
+ raise ArgumentError, "Credit cost rate must be a whole number (decimals are not allowed)" unless self == self.to_i
18
+
19
+ # Convert common units to their base unit
20
+ unit = case unit.to_s.downcase
21
+ when "mb", "megabyte", "megabytes"
22
+ :mb
23
+ when "kb", "kilobyte", "kilobytes"
24
+ :kb
25
+ when "gb", "gigabyte", "gigabytes"
26
+ :gb
27
+ when "unit", "units"
28
+ :units
29
+ else
30
+ unit.to_sym
31
+ end
32
+
33
+ UsageCredits::Cost::Variable.new(self, unit)
34
+ end
35
+ alias_method :credit_per, :credits_per
36
+
37
+ def dollars
38
+ self * 100 # Convert to cents for payment processors
39
+ end
40
+ alias_method :dollar, :dollars
41
+ alias_method :euro, :dollars
42
+ alias_method :euros, :dollars
43
+ alias_method :pound, :dollars
44
+ alias_method :pounds, :dollars
45
+
46
+ def cents
47
+ self
48
+ end
49
+ alias_method :cent, :cents
50
+ end
51
+
52
+ # This is what allows us to write .credit amounts as Procs, like:
53
+ # cost ->(params) { 2 * params[:variable] }.credits
54
+ class Proc
55
+ def credits
56
+ UsageCredits::Cost::Fixed.new(self)
57
+ end
58
+ alias_method :credit, :credits
59
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ module Cost
5
+ # Base class for all credit cost calculations.
6
+ # Subclasses implement different ways of calculating credit costs:
7
+ # - Fixed: Simple fixed amount
8
+ # - Variable: Amount based on units (MB, etc)
9
+ # - Compound: Multiple costs added together
10
+ class Base
11
+ attr_reader :amount
12
+
13
+ def initialize(amount)
14
+ validate_amount!(amount) unless amount.is_a?(Proc)
15
+ @amount = amount
16
+ end
17
+
18
+ def +(other)
19
+ case other
20
+ when Fixed, Variable
21
+ Compound.new(self, other)
22
+ else
23
+ raise ArgumentError, "Cannot add #{other.class} to #{self.class}"
24
+ end
25
+ end
26
+
27
+ def to_i
28
+ calculate({})
29
+ end
30
+
31
+ protected
32
+
33
+ def validate_amount!(amount)
34
+ unless amount == amount.to_i
35
+ raise ArgumentError, "Credit amount must be a whole number (got: #{amount})"
36
+ end
37
+ if amount.negative?
38
+ raise ArgumentError, "Credit amount cannot be negative (got: #{amount})"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ module Cost
5
+ # Compound credit cost that adds multiple costs together
6
+ # Example: base cost + per MB cost
7
+ class Compound < Base
8
+ attr_reader :costs
9
+
10
+ def initialize(*costs)
11
+ @costs = costs.flatten
12
+ end
13
+
14
+ def calculate(params = {})
15
+ total = costs.sum { |cost| cost.calculate(params) }
16
+ CreditCalculator.apply_rounding(total)
17
+ end
18
+
19
+ def +(other)
20
+ case other
21
+ when Fixed, Variable
22
+ self.class.new(costs + [other])
23
+ else
24
+ raise ArgumentError, "Cannot add #{other.class} to #{self.class}"
25
+ end
26
+ end
27
+ end
28
+
29
+ def self.credits(amount)
30
+ Fixed.new(amount)
31
+ end
32
+
33
+ class_eval do
34
+ singleton_class.alias_method :credit, :credits
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ module Cost
5
+ # Fixed credit cost (e.g., always costs 10 credits)
6
+ class Fixed < Base
7
+ attr_reader :period
8
+
9
+ def initialize(amount)
10
+ @amount = amount
11
+ @period = nil # Will default to 1.month in CreditSubscriptionPlan
12
+ end
13
+
14
+ def calculate(params = {})
15
+ value = amount.is_a?(Proc) ? amount.call(params) : amount
16
+ case value
17
+ when UsageCredits::Cost::Fixed
18
+ value.calculate(params)
19
+ else
20
+ validate_amount!(value)
21
+ value.to_i
22
+ end
23
+ end
24
+
25
+ # Set the recurring period for subscription plans
26
+ # @param period [Symbol, ActiveSupport::Duration, nil] The period (e.g., :month, 2.months, 15.days)
27
+ # @return [self]
28
+ def every(period = nil)
29
+ @period = period # nil will default to 1.month in CreditSubscriptionPlan
30
+ self
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ module Cost
5
+ # Variable credit cost based on units (e.g., 1 credit per MB)
6
+ class Variable < Base
7
+ attr_reader :unit
8
+
9
+ def initialize(amount, unit)
10
+ super(amount)
11
+ @unit = unit.to_sym
12
+ end
13
+
14
+ def calculate(params = {})
15
+ size = extract_size(params)
16
+ raw_cost = amount * size
17
+ CreditCalculator.apply_rounding(raw_cost)
18
+ end
19
+
20
+ private
21
+
22
+ def extract_size(params)
23
+ case unit
24
+ when :mb
25
+ # First check for direct MB value
26
+ if params[:mb]
27
+ CreditCalculator.apply_rounding(params[:mb].to_f)
28
+ # Then check for bytes that need conversion
29
+ elsif params[:size]
30
+ CreditCalculator.apply_rounding(params[:size].to_f / 1.megabyte)
31
+ else
32
+ 0
33
+ end
34
+ when :units
35
+ CreditCalculator.apply_rounding(params.fetch(:units, 0).to_f)
36
+ else
37
+ raise ArgumentError, "Unknown unit: #{unit}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Rails engine for UsageCredits
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace UsageCredits
7
+
8
+ # Ensure our models load first
9
+ config.autoload_paths << File.expand_path("../models", __dir__)
10
+ config.autoload_paths << File.expand_path("../models/concerns", __dir__)
11
+
12
+ # Set up autoloading paths
13
+ initializer "usage_credits.autoload", before: :set_autoload_paths do |app|
14
+ app.config.autoload_paths << root.join("lib")
15
+ app.config.autoload_paths << root.join("lib/usage_credits/models")
16
+ app.config.autoload_paths << root.join("lib/usage_credits/models/concerns")
17
+ end
18
+
19
+ # Add has_credits method to ActiveRecord::Base
20
+ initializer "usage_credits.active_record" do
21
+ ActiveSupport.on_load(:active_record) do
22
+ extend UsageCredits::HasWallet::ClassMethods
23
+ end
24
+ end
25
+
26
+ initializer "usage_credits.pay_integration" do
27
+ ActiveSupport.on_load(:pay) do
28
+ Pay::Subscription.include UsageCredits::PaySubscriptionExtension
29
+ Pay::Charge.include UsageCredits::PayChargeExtension
30
+ end
31
+ end
32
+
33
+ initializer "usage_credits.configs" do
34
+ # Initialize any config settings
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Centralized credit calculations used throughout the gem.
5
+ # This ensures consistent rounding and credit math everywhere.
6
+ module CreditCalculator
7
+ module_function
8
+
9
+ # Apply the configured rounding strategy to a credit amount
10
+ # Always defaults to ceiling to ensure we never undercharge
11
+ def apply_rounding(amount)
12
+ case UsageCredits.configuration.rounding_strategy
13
+ when :round
14
+ amount.round
15
+ when :floor
16
+ amount.floor
17
+ when :ceil
18
+ amount.ceil
19
+ else
20
+ amount.ceil # Default to ceiling to never undercharge
21
+ end
22
+ end
23
+
24
+ # Convert a monetary amount to credits
25
+ def money_to_credits(cents, exchange_rate)
26
+ apply_rounding(cents * exchange_rate / 100.0)
27
+ end
28
+
29
+ # Convert credits to a monetary amount
30
+ def credits_to_money(credits, exchange_rate)
31
+ apply_rounding(credits * 100.0 / exchange_rate)
32
+ end
33
+ end
34
+ end