usage_credits 0.1.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.
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