usage_credits 0.4.0 → 1.0.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/CHANGELOG.md +72 -0
- data/README.md +246 -13
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
- data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
- data/lib/usage_credits/callbacks.rb +9 -54
- data/lib/usage_credits/configuration.rb +43 -13
- data/lib/usage_credits/core_ext/numeric.rb +12 -16
- data/lib/usage_credits/cost/base.rb +1 -6
- data/lib/usage_credits/cost/compound.rb +3 -2
- data/lib/usage_credits/cost/fixed.rb +1 -2
- data/lib/usage_credits/cost/variable.rb +23 -8
- data/lib/usage_credits/engine.rb +8 -12
- data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
- data/lib/usage_credits/helpers/credits_helper.rb +9 -11
- data/lib/usage_credits/helpers/period_parser.rb +31 -9
- data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
- data/lib/usage_credits/models/allocation.rb +15 -20
- data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
- data/lib/usage_credits/models/credit_pack.rb +34 -13
- data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
- data/lib/usage_credits/models/fulfillment.rb +23 -48
- data/lib/usage_credits/models/operation.rb +36 -28
- data/lib/usage_credits/models/transaction.rb +55 -152
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +219 -314
- data/lib/usage_credits/railtie.rb +4 -13
- data/lib/usage_credits/services/fulfillment_service.rb +146 -22
- data/lib/usage_credits/subscription_terms.rb +131 -0
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +16 -3
- metadata +33 -20
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -48
- data/AGENTS.md +0 -5
- data/Appraisals +0 -43
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -32
- data/gemfiles/pay_10.0.gemfile +0 -41
- data/gemfiles/pay_11.0.gemfile +0 -41
- data/gemfiles/pay_8.3.gemfile +0 -41
- data/gemfiles/pay_9.0.gemfile +0 -41
- data/gemfiles/rails_7.2.gemfile +0 -41
- data/gemfiles/rails_8.1.gemfile +0 -41
|
@@ -9,11 +9,10 @@ module UsageCredits
|
|
|
9
9
|
#
|
|
10
10
|
# @see PayChargeExtension for the actual payment processing, credit pack fulfilling and refund handling
|
|
11
11
|
class CreditPack
|
|
12
|
-
|
|
13
12
|
attr_reader :name,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
:credits, :bonus_credits,
|
|
14
|
+
:price_cents, :price_currency,
|
|
15
|
+
:metadata
|
|
17
16
|
|
|
18
17
|
def initialize(name)
|
|
19
18
|
@name = name
|
|
@@ -30,17 +29,17 @@ module UsageCredits
|
|
|
30
29
|
|
|
31
30
|
# Set the base number of credits
|
|
32
31
|
def gives(amount)
|
|
33
|
-
@credits = amount
|
|
32
|
+
@credits = normalize_whole_number!(amount, "Credits", minimum: 1)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
# Set bonus credits (e.g., for promotions)
|
|
37
36
|
def bonus(amount)
|
|
38
|
-
@bonus_credits = amount
|
|
37
|
+
@bonus_credits = normalize_whole_number!(amount, "Bonus credits", minimum: 0)
|
|
39
38
|
end
|
|
40
39
|
|
|
41
40
|
# Set the price in cents (e.g., 4900 for $49.00)
|
|
42
41
|
def costs(cents)
|
|
43
|
-
@price_cents = cents
|
|
42
|
+
@price_cents = normalize_whole_number!(cents, "Price cents", minimum: 1)
|
|
44
43
|
end
|
|
45
44
|
alias_method :cost, :costs
|
|
46
45
|
|
|
@@ -48,7 +47,7 @@ module UsageCredits
|
|
|
48
47
|
def currency(currency)
|
|
49
48
|
currency = currency.to_s.downcase.to_sym
|
|
50
49
|
unless UsageCredits::Configuration::VALID_CURRENCIES.include?(currency)
|
|
51
|
-
raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(
|
|
50
|
+
raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(", ")}"
|
|
52
51
|
end
|
|
53
52
|
@price_currency = currency.to_s.upcase
|
|
54
53
|
end
|
|
@@ -158,20 +157,22 @@ module UsageCredits
|
|
|
158
157
|
raise ArgumentError, "User must have a payment processor" unless user.respond_to?(:payment_processor) && user.payment_processor
|
|
159
158
|
|
|
160
159
|
# Merge custom metadata with base_metadata (base_metadata takes precedence for critical fields)
|
|
161
|
-
custom_metadata = options
|
|
162
|
-
merged_metadata = custom_metadata.merge(base_metadata)
|
|
160
|
+
custom_metadata = merge_hash_options!(options, :metadata)
|
|
161
|
+
merged_metadata = UsageCredits::ProcessorMetadata.normalize(custom_metadata.merge(base_metadata))
|
|
163
162
|
|
|
164
163
|
# Handle payment_intent_data specially to preserve metadata
|
|
165
164
|
# We dup to avoid mutating the caller's original hash
|
|
166
|
-
custom_payment_intent_data = (options
|
|
167
|
-
custom_pi_metadata = custom_payment_intent_data
|
|
165
|
+
custom_payment_intent_data = merge_hash_options!(options, :payment_intent_data)
|
|
166
|
+
custom_pi_metadata = merge_hash_options!(custom_payment_intent_data, :metadata)
|
|
168
167
|
merged_payment_intent_data = custom_payment_intent_data.merge(
|
|
169
|
-
metadata: custom_pi_metadata.merge(base_metadata)
|
|
168
|
+
metadata: UsageCredits::ProcessorMetadata.normalize(custom_pi_metadata.merge(base_metadata))
|
|
170
169
|
)
|
|
171
170
|
|
|
172
171
|
# Remove protected parameters that could break credit fulfillment
|
|
173
172
|
options.delete(:mode)
|
|
173
|
+
options.delete("mode")
|
|
174
174
|
options.delete(:line_items)
|
|
175
|
+
options.delete("line_items")
|
|
175
176
|
|
|
176
177
|
user.payment_processor.checkout(
|
|
177
178
|
mode: "payment",
|
|
@@ -202,5 +203,25 @@ module UsageCredits
|
|
|
202
203
|
price_currency: price_currency
|
|
203
204
|
}
|
|
204
205
|
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def merge_hash_options!(options, key)
|
|
210
|
+
values = [options.delete(key.to_s), options.delete(key)].compact
|
|
211
|
+
values.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |value, merged|
|
|
212
|
+
unless value.respond_to?(:to_h)
|
|
213
|
+
raise ArgumentError, "#{key} must be hash-like"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
merged.merge!(value.to_h.with_indifferent_access)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def normalize_whole_number!(amount, name, minimum:)
|
|
221
|
+
amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed)
|
|
222
|
+
value = Wallets::WholeNumber.parse(amount, name: name)
|
|
223
|
+
raise ArgumentError, "#{name} must be at least #{minimum}" if value < minimum
|
|
224
|
+
value
|
|
225
|
+
end
|
|
205
226
|
end
|
|
206
227
|
end
|
|
@@ -10,12 +10,12 @@ module UsageCredits
|
|
|
10
10
|
# @see PaySubscriptionExtension for the actual credit fulfillment logic
|
|
11
11
|
class CreditSubscriptionPlan
|
|
12
12
|
attr_reader :name,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
:processor_plan_ids,
|
|
14
|
+
:fulfillment_period, :credits_per_period,
|
|
15
|
+
:signup_bonus_credits, :trial_credits,
|
|
16
|
+
:rollover_enabled,
|
|
17
|
+
:expire_credits_on_cancel, :credit_expiration_period,
|
|
18
|
+
:metadata
|
|
19
19
|
|
|
20
20
|
attr_writer :fulfillment_period
|
|
21
21
|
|
|
@@ -41,23 +41,23 @@ module UsageCredits
|
|
|
41
41
|
# Set base credits given each period
|
|
42
42
|
def gives(amount)
|
|
43
43
|
if amount.is_a?(UsageCredits::Cost::Fixed)
|
|
44
|
-
@credits_per_period = amount.amount
|
|
44
|
+
@credits_per_period = normalize_whole_number!(amount.amount, "Credits per period", minimum: 0)
|
|
45
45
|
@fulfillment_period = UsageCredits::PeriodParser.normalize_period(amount.period || 1.month)
|
|
46
46
|
self
|
|
47
47
|
else
|
|
48
|
-
@credits_per_period = amount
|
|
48
|
+
@credits_per_period = normalize_whole_number!(amount, "Credits per period", minimum: 0)
|
|
49
49
|
CreditGiver.new(self)
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# One-time signup bonus credits
|
|
54
54
|
def signup_bonus(amount)
|
|
55
|
-
@signup_bonus_credits = amount
|
|
55
|
+
@signup_bonus_credits = normalize_whole_number!(amount, "Signup bonus credits", minimum: 0)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# Credits given during trial period
|
|
59
59
|
def trial_includes(amount)
|
|
60
|
-
@trial_credits = amount
|
|
60
|
+
@trial_credits = normalize_whole_number!(amount, "Trial credits", minimum: 0)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Configure whether unused credits roll over between periods
|
|
@@ -75,6 +75,12 @@ module UsageCredits
|
|
|
75
75
|
# @param duration [ActiveSupport::Duration, nil] Grace period before credits expire
|
|
76
76
|
# @return [void]
|
|
77
77
|
def expire_after(duration)
|
|
78
|
+
unless duration.nil?
|
|
79
|
+
seconds = duration.is_a?(ActiveSupport::Duration) ? duration.value : duration
|
|
80
|
+
seconds = Wallets::WholeNumber.parse(seconds, name: "Credit expiration period")
|
|
81
|
+
raise ArgumentError, "Credit expiration period cannot be negative" if seconds.negative?
|
|
82
|
+
end
|
|
83
|
+
|
|
78
84
|
@expire_credits_on_cancel = true
|
|
79
85
|
@credit_expiration_period = duration
|
|
80
86
|
end
|
|
@@ -170,7 +176,7 @@ module UsageCredits
|
|
|
170
176
|
ids = plan_id_for(:stripe)
|
|
171
177
|
return {} if ids.nil?
|
|
172
178
|
return ids if ids.is_a?(Hash)
|
|
173
|
-
{
|
|
179
|
+
{default: ids} # Wrap single ID in hash for consistency
|
|
174
180
|
end
|
|
175
181
|
|
|
176
182
|
# Check if this plan matches a given processor price ID
|
|
@@ -180,7 +186,7 @@ module UsageCredits
|
|
|
180
186
|
def matches_processor_id?(processor_id)
|
|
181
187
|
processor_plan_ids.values.any? do |ids|
|
|
182
188
|
if ids.is_a?(Hash)
|
|
183
|
-
ids.
|
|
189
|
+
ids.value?(processor_id)
|
|
184
190
|
else
|
|
185
191
|
ids == processor_id
|
|
186
192
|
end
|
|
@@ -202,15 +208,18 @@ module UsageCredits
|
|
|
202
208
|
# plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :year)
|
|
203
209
|
def create_checkout_session(user, success_url:, cancel_url:, processor: :stripe, period: nil)
|
|
204
210
|
raise ArgumentError, "User must respond to payment_processor" unless user.respond_to?(:payment_processor)
|
|
211
|
+
raise ArgumentError, "User must have a payment processor" unless user.payment_processor
|
|
205
212
|
raise ArgumentError, "No fulfillment period configured for plan: #{name}" unless fulfillment_period
|
|
206
213
|
|
|
214
|
+
processor = processor.to_sym
|
|
215
|
+
|
|
207
216
|
plan_ids = plan_id_for(processor)
|
|
208
217
|
raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless plan_ids
|
|
209
218
|
|
|
210
219
|
# Determine which price ID to use
|
|
211
220
|
plan_id = if plan_ids.is_a?(Hash)
|
|
212
221
|
# Multi-period plan: period is required
|
|
213
|
-
raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(
|
|
222
|
+
raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(", ")}). Please specify period: parameter (e.g., period: :month)" if period.nil?
|
|
214
223
|
plan_ids[period.to_sym] || raise(ArgumentError, "Period #{period.inspect} not found. Available periods: #{plan_ids.keys.inspect}")
|
|
215
224
|
else
|
|
216
225
|
# Single-price plan: use the ID directly
|
|
@@ -251,6 +260,10 @@ module UsageCredits
|
|
|
251
260
|
end
|
|
252
261
|
|
|
253
262
|
def create_stripe_checkout_session(user, plan_id, success_url, cancel_url)
|
|
263
|
+
processor_metadata = UsageCredits::ProcessorMetadata.normalize(
|
|
264
|
+
base_metadata.merge(processor_plan: plan_id)
|
|
265
|
+
)
|
|
266
|
+
|
|
254
267
|
user.payment_processor.checkout(
|
|
255
268
|
mode: "subscription",
|
|
256
269
|
line_items: [{
|
|
@@ -259,7 +272,7 @@ module UsageCredits
|
|
|
259
272
|
}],
|
|
260
273
|
success_url: success_url,
|
|
261
274
|
cancel_url: cancel_url,
|
|
262
|
-
subscription_data: {
|
|
275
|
+
subscription_data: {metadata: processor_metadata}
|
|
263
276
|
)
|
|
264
277
|
end
|
|
265
278
|
|
|
@@ -278,6 +291,15 @@ module UsageCredits
|
|
|
278
291
|
}
|
|
279
292
|
end
|
|
280
293
|
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
def normalize_whole_number!(amount, name, minimum:)
|
|
297
|
+
amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed)
|
|
298
|
+
value = Wallets::WholeNumber.parse(amount, name: name)
|
|
299
|
+
raise ArgumentError, "#{name} must be at least #{minimum}" if value < minimum
|
|
300
|
+
value
|
|
301
|
+
end
|
|
302
|
+
|
|
281
303
|
# =========================================
|
|
282
304
|
# DSL Helper Classes
|
|
283
305
|
# =========================================
|
|
@@ -300,6 +322,5 @@ module UsageCredits
|
|
|
300
322
|
@plan
|
|
301
323
|
end
|
|
302
324
|
end
|
|
303
|
-
|
|
304
325
|
end
|
|
305
326
|
end
|
|
@@ -5,51 +5,38 @@ module UsageCredits
|
|
|
5
5
|
# including credit pack purchases and subscriptions.
|
|
6
6
|
# Some of this credit-giving actions are repeating in nature (i.e.: subscriptions), some are not (one-time purchases)
|
|
7
7
|
class Fulfillment < ApplicationRecord
|
|
8
|
+
include Wallets::HasMetadata
|
|
9
|
+
|
|
8
10
|
self.table_name = "usage_credits_fulfillments"
|
|
9
11
|
|
|
10
12
|
belongs_to :wallet
|
|
11
13
|
belongs_to :source, polymorphic: true, optional: true
|
|
12
14
|
|
|
13
15
|
validates :wallet, presence: true
|
|
14
|
-
validates :source_id, uniqueness: {
|
|
15
|
-
validates :
|
|
16
|
-
validates :
|
|
16
|
+
validates :source_id, uniqueness: {scope: :source_type}, if: :source_id?
|
|
17
|
+
validates :source_type, presence: true, if: :source_id?
|
|
18
|
+
validates :source_id, presence: true, if: :source_type?
|
|
19
|
+
validates :credits_last_fulfillment,
|
|
20
|
+
presence: true,
|
|
21
|
+
numericality: {only_integer: true, greater_than_or_equal_to: 0}
|
|
22
|
+
validates :fulfillment_type,
|
|
23
|
+
presence: true,
|
|
24
|
+
inclusion: {in: %w[subscription credit_pack manual]}
|
|
17
25
|
validate :valid_fulfillment_period_format, if: :fulfillment_period?
|
|
18
|
-
validates :next_fulfillment_at, comparison: {
|
|
26
|
+
validates :next_fulfillment_at, comparison: {greater_than: :last_fulfilled_at},
|
|
19
27
|
if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? }
|
|
20
28
|
|
|
21
|
-
# =========================================
|
|
22
|
-
# Metadata Handling
|
|
23
|
-
# =========================================
|
|
24
|
-
|
|
25
|
-
# Sync in-place modifications to metadata before saving
|
|
26
|
-
before_save :sync_metadata_cache
|
|
27
|
-
|
|
28
|
-
# Get metadata with indifferent access (string/symbol keys)
|
|
29
|
-
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
30
|
-
def metadata
|
|
31
|
-
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Set metadata, ensuring consistent storage format
|
|
35
|
-
def metadata=(hash)
|
|
36
|
-
@indifferent_metadata = nil # Clear cache
|
|
37
|
-
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Clear metadata cache on reload to ensure fresh data from database
|
|
41
|
-
def reload(*)
|
|
42
|
-
@indifferent_metadata = nil
|
|
43
|
-
super
|
|
44
|
-
end
|
|
45
|
-
|
|
46
29
|
# Only get fulfillments that are due AND not stopped
|
|
47
30
|
scope :due_for_fulfillment, -> {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.where(
|
|
31
|
+
table = arel_table
|
|
32
|
+
where(table[:next_fulfillment_at].lteq(Time.current))
|
|
33
|
+
.where(table[:stops_at].eq(nil).or(table[:stops_at].gt(Time.current)))
|
|
34
|
+
.where(table[:last_fulfilled_at].eq(nil).or(table[:next_fulfillment_at].gt(table[:last_fulfilled_at])))
|
|
35
|
+
}
|
|
36
|
+
scope :active, -> {
|
|
37
|
+
table = arel_table
|
|
38
|
+
where(table[:stops_at].eq(nil).or(table[:stops_at].gt(Time.current)))
|
|
51
39
|
}
|
|
52
|
-
scope :active, -> { where("stops_at IS NULL OR stops_at > ?", Time.current) }
|
|
53
40
|
|
|
54
41
|
# Alias for backward compatibility - will be removed in next version
|
|
55
42
|
scope :pending, -> { due_for_fulfillment }
|
|
@@ -83,26 +70,15 @@ module UsageCredits
|
|
|
83
70
|
# we use current time as the base to avoid scheduling multiple rapid fulfillments.
|
|
84
71
|
# This ensures smooth recovery from missed fulfillments by scheduling the next one
|
|
85
72
|
# from the current time rather than the missed fulfillment time.
|
|
86
|
-
base_time = next_fulfillment_at > Time.current ? next_fulfillment_at : Time.current
|
|
73
|
+
base_time = (next_fulfillment_at > Time.current) ? next_fulfillment_at : Time.current
|
|
87
74
|
|
|
88
|
-
base_time + UsageCredits::PeriodParser.
|
|
75
|
+
base_time + UsageCredits::PeriodParser.parse_persisted_period(fulfillment_period)
|
|
89
76
|
end
|
|
90
77
|
|
|
91
78
|
private
|
|
92
79
|
|
|
93
|
-
# Sync in-place modifications to the cached metadata back to the attribute
|
|
94
|
-
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
95
|
-
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
96
|
-
def sync_metadata_cache
|
|
97
|
-
if @indifferent_metadata
|
|
98
|
-
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
99
|
-
elsif read_attribute(:metadata).nil?
|
|
100
|
-
write_attribute(:metadata, {})
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
80
|
def valid_fulfillment_period_format
|
|
105
|
-
unless UsageCredits::PeriodParser.
|
|
81
|
+
unless UsageCredits::PeriodParser.valid_persisted_period_format?(fulfillment_period)
|
|
106
82
|
errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units")
|
|
107
83
|
end
|
|
108
84
|
end
|
|
@@ -122,6 +98,5 @@ module UsageCredits
|
|
|
122
98
|
errors.add(:next_fulfillment_at, "should be nil for non-recurring fulfillments") unless new_record?
|
|
123
99
|
end
|
|
124
100
|
end
|
|
125
|
-
|
|
126
101
|
end
|
|
127
102
|
end
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
module UsageCredits
|
|
4
4
|
# A DSL to define an operation that consumes credits when performed.
|
|
5
5
|
class Operation
|
|
6
|
-
|
|
7
6
|
attr_reader :name, # Operation identifier (e.g., :process_video)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
:cost_calculator, # Lambda or Fixed that calculates credit cost
|
|
8
|
+
:validation_rules, # Array of [condition, message] pairs
|
|
9
|
+
:metadata # Custom data for your app's use
|
|
11
10
|
|
|
12
11
|
def initialize(name, &block)
|
|
13
12
|
@name = name
|
|
@@ -61,16 +60,9 @@ module UsageCredits
|
|
|
61
60
|
normalized_params = normalize_params(params)
|
|
62
61
|
validate!(normalized_params) # Ensure params are valid before calculating
|
|
63
62
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
result = cost_calculator.call(normalized_params)
|
|
68
|
-
raise ArgumentError, "Credit amount must be a whole number (got: #{result})" unless result == result.to_i
|
|
69
|
-
raise ArgumentError, "Credit amount cannot be negative (got: #{result})" if result.negative?
|
|
70
|
-
result
|
|
71
|
-
else
|
|
72
|
-
cost_calculator.calculate(normalized_params)
|
|
73
|
-
end
|
|
63
|
+
# `costs` wraps every configured value (including a Proc) in a Cost
|
|
64
|
+
# object, so there is one calculation/validation path here.
|
|
65
|
+
total = cost_calculator.calculate(normalized_params)
|
|
74
66
|
|
|
75
67
|
# Apply configured rounding strategy
|
|
76
68
|
CreditCalculator.apply_rounding(total)
|
|
@@ -92,7 +84,7 @@ module UsageCredits
|
|
|
92
84
|
begin
|
|
93
85
|
result = condition.call(normalized)
|
|
94
86
|
raise InvalidOperation, message unless result
|
|
95
|
-
rescue
|
|
87
|
+
rescue => e
|
|
96
88
|
raise InvalidOperation, "Validation error: #{e.message}"
|
|
97
89
|
end
|
|
98
90
|
end
|
|
@@ -103,10 +95,12 @@ module UsageCredits
|
|
|
103
95
|
# =========================================
|
|
104
96
|
|
|
105
97
|
# Create an audit record of this operation
|
|
106
|
-
def to_audit_hash(params =
|
|
98
|
+
def to_audit_hash(params = nil, cost: nil, **keyword_params)
|
|
99
|
+
params = (params || {}).merge(keyword_params)
|
|
100
|
+
|
|
107
101
|
{
|
|
108
102
|
operation: name,
|
|
109
|
-
cost: calculate_cost(params),
|
|
103
|
+
cost: cost.nil? ? calculate_cost(params) : cost,
|
|
110
104
|
params: params,
|
|
111
105
|
metadata: metadata,
|
|
112
106
|
executed_at: Time.current,
|
|
@@ -128,19 +122,23 @@ module UsageCredits
|
|
|
128
122
|
|
|
129
123
|
# Handle different size specifications
|
|
130
124
|
size = if params[:mb]
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
125
|
+
normalize_quantity(params[:mb], :mb)
|
|
126
|
+
elsif params[:kb]
|
|
127
|
+
normalize_quantity(params[:kb], :kb) / 1024.0
|
|
128
|
+
elsif params[:gb]
|
|
129
|
+
normalize_quantity(params[:gb], :gb) * 1024.0
|
|
130
|
+
elsif params[:size_mb]
|
|
131
|
+
normalize_quantity(params[:size_mb], :size_mb)
|
|
132
|
+
elsif params[:size_megabytes]
|
|
133
|
+
normalize_quantity(params[:size_megabytes], :size_megabytes)
|
|
134
|
+
elsif params[:size]
|
|
135
|
+
normalize_quantity(params[:size], :size) / 1.megabyte
|
|
136
|
+
else
|
|
137
|
+
0.0
|
|
138
|
+
end
|
|
141
139
|
|
|
142
140
|
# Handle generic unit-based operations
|
|
143
|
-
units = params[:units]
|
|
141
|
+
units = normalize_quantity(params[:units], :units) if params[:units]
|
|
144
142
|
|
|
145
143
|
params.merge(
|
|
146
144
|
size: (size * 1.megabyte).to_i, # Raw bytes
|
|
@@ -149,5 +147,15 @@ module UsageCredits
|
|
|
149
147
|
)
|
|
150
148
|
end
|
|
151
149
|
|
|
150
|
+
def normalize_quantity(value, name)
|
|
151
|
+
number = Float(value)
|
|
152
|
+
unless number.finite? && !number.negative?
|
|
153
|
+
raise ArgumentError, "#{name} must be a finite, non-negative number"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
number
|
|
157
|
+
rescue ArgumentError, TypeError
|
|
158
|
+
raise ArgumentError, "#{name} must be a finite, non-negative number"
|
|
159
|
+
end
|
|
152
160
|
end
|
|
153
161
|
end
|