usage_credits 0.5.0 → 1.0.1
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 +75 -0
- data/README.md +120 -29
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
- 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 +25 -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 +51 -158
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +224 -312
- 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 -37
- 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
|
@@ -1,67 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module UsageCredits
|
|
4
|
-
#
|
|
5
|
-
# Handles executing callbacks with error isolation
|
|
4
|
+
# usage_credits adapter for wallets' shared callback dispatcher.
|
|
6
5
|
module Callbacks
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# Dispatch a callback event with error isolation
|
|
10
|
-
# Callbacks should never break the main operation
|
|
11
|
-
#
|
|
12
|
-
# @param event [Symbol] The event type (e.g., :credits_added, :low_balance_reached)
|
|
13
|
-
# @param context_data [Hash] Data to pass to the callback via CallbackContext
|
|
14
|
-
def dispatch(event, **context_data)
|
|
15
|
-
config = UsageCredits.configuration
|
|
16
|
-
callback = config.public_send(:"on_#{event}_callback")
|
|
17
|
-
|
|
18
|
-
return unless callback.is_a?(Proc)
|
|
19
|
-
|
|
20
|
-
context = CallbackContext.new(event: event, **context_data)
|
|
6
|
+
extend Wallets::CallbackDispatcher
|
|
21
7
|
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Execute callback with error isolation and arity handling
|
|
26
|
-
#
|
|
27
|
-
# @param callback [Proc] The callback to execute
|
|
28
|
-
# @param context [CallbackContext] The context to pass
|
|
29
|
-
def execute_safely(callback, context)
|
|
30
|
-
case callback.arity
|
|
31
|
-
when 1, -1, -2 # Accepts one arg or variable args
|
|
32
|
-
callback.call(context)
|
|
33
|
-
when 0
|
|
34
|
-
callback.call
|
|
35
|
-
else
|
|
36
|
-
log_warn "[UsageCredits] Callback has unexpected arity (#{callback.arity}). Expected 0 or 1."
|
|
37
|
-
end
|
|
38
|
-
rescue StandardError => e
|
|
39
|
-
# Log but don't re-raise - callbacks should never break credit operations
|
|
40
|
-
log_error "[UsageCredits] Callback error for #{context.event}: #{e.class}: #{e.message}"
|
|
41
|
-
log_debug e.backtrace.join("\n")
|
|
42
|
-
end
|
|
8
|
+
module_function
|
|
43
9
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
47
|
-
Rails.logger.error(message)
|
|
48
|
-
else
|
|
49
|
-
warn message
|
|
50
|
-
end
|
|
10
|
+
def callback_configuration
|
|
11
|
+
UsageCredits.configuration
|
|
51
12
|
end
|
|
52
13
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
Rails.logger.warn(message)
|
|
56
|
-
else
|
|
57
|
-
warn message
|
|
58
|
-
end
|
|
14
|
+
def callback_context_class
|
|
15
|
+
UsageCredits::CallbackContext
|
|
59
16
|
end
|
|
60
17
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
Rails.logger.debug(message)
|
|
64
|
-
end
|
|
18
|
+
def callback_log_prefix
|
|
19
|
+
"[UsageCredits]"
|
|
65
20
|
end
|
|
66
21
|
end
|
|
67
22
|
end
|
|
@@ -33,6 +33,12 @@ module UsageCredits
|
|
|
33
33
|
# Custom transaction categories that extend the default set
|
|
34
34
|
attr_reader :additional_categories
|
|
35
35
|
|
|
36
|
+
# Table prefix for usage_credits tables (for wallets gem compatibility)
|
|
37
|
+
# Note: usage_credits uses fixed table names, so this is always "usage_credits_"
|
|
38
|
+
def table_prefix
|
|
39
|
+
"usage_credits_"
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
# Minimum allowed fulfillment period for subscription plans.
|
|
37
43
|
# Defaults to 1.day to prevent accidental 1-second refill loops in production.
|
|
38
44
|
# Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
|
|
@@ -53,12 +59,12 @@ module UsageCredits
|
|
|
53
59
|
# =========================================
|
|
54
60
|
|
|
55
61
|
attr_reader :on_credits_added_callback,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
:on_credits_deducted_callback,
|
|
63
|
+
:on_low_balance_reached_callback,
|
|
64
|
+
:on_balance_depleted_callback,
|
|
65
|
+
:on_insufficient_credits_callback,
|
|
66
|
+
:on_subscription_credits_awarded_callback,
|
|
67
|
+
:on_credit_pack_purchased_callback
|
|
62
68
|
|
|
63
69
|
def initialize
|
|
64
70
|
# Initialize empty data stores
|
|
@@ -159,15 +165,18 @@ module UsageCredits
|
|
|
159
165
|
def default_currency=(value)
|
|
160
166
|
value = value.to_s.downcase.to_sym
|
|
161
167
|
unless VALID_CURRENCIES.include?(value)
|
|
162
|
-
raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(
|
|
168
|
+
raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(", ")}"
|
|
163
169
|
end
|
|
164
170
|
@default_currency = value
|
|
165
171
|
end
|
|
166
172
|
|
|
167
|
-
# Set low balance threshold with validation
|
|
173
|
+
# Set low balance threshold with validation. Accepts plain integers and
|
|
174
|
+
# the DSL form the initializer template shows (`100.credits`, a
|
|
175
|
+
# Cost::Fixed) — anything else fails WholeNumber.parse loudly.
|
|
168
176
|
def low_balance_threshold=(value)
|
|
169
177
|
if value
|
|
170
|
-
value = value.to_i
|
|
178
|
+
value = value.to_i if value.is_a?(UsageCredits::Cost::Fixed)
|
|
179
|
+
value = Wallets::WholeNumber.parse(value, name: "Low balance threshold", allow_string: true)
|
|
171
180
|
raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative?
|
|
172
181
|
end
|
|
173
182
|
@low_balance_threshold = value
|
|
@@ -183,7 +192,10 @@ module UsageCredits
|
|
|
183
192
|
end
|
|
184
193
|
|
|
185
194
|
def fulfillment_grace_period=(value)
|
|
186
|
-
|
|
195
|
+
# Only actual nil/numeric zero select the safe one-second fallback.
|
|
196
|
+
# String coercion previously made arbitrary garbage ("nope".to_i == 0)
|
|
197
|
+
# silently valid configuration; non-Duration strings now fail closed.
|
|
198
|
+
if value.nil? || value == 0
|
|
187
199
|
@fulfillment_grace_period = 1.second
|
|
188
200
|
return
|
|
189
201
|
end
|
|
@@ -296,19 +308,19 @@ module UsageCredits
|
|
|
296
308
|
def validate_currency!
|
|
297
309
|
raise ArgumentError, "Default currency can't be blank" if default_currency.blank?
|
|
298
310
|
unless VALID_CURRENCIES.include?(default_currency.to_s.downcase.to_sym)
|
|
299
|
-
raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(
|
|
311
|
+
raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(", ")}"
|
|
300
312
|
end
|
|
301
313
|
end
|
|
302
314
|
|
|
303
315
|
def validate_threshold!
|
|
304
|
-
if @low_balance_threshold
|
|
316
|
+
if @low_balance_threshold&.negative?
|
|
305
317
|
raise ArgumentError, "Low balance threshold must be greater than or equal to zero"
|
|
306
318
|
end
|
|
307
319
|
end
|
|
308
320
|
|
|
309
321
|
def validate_rounding_strategy!
|
|
310
322
|
unless VALID_ROUNDING_STRATEGIES.include?(@rounding_strategy)
|
|
311
|
-
raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(
|
|
323
|
+
raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(", ")}"
|
|
312
324
|
end
|
|
313
325
|
end
|
|
314
326
|
|
|
@@ -7,28 +7,24 @@ require "active_support/core_ext/numeric"
|
|
|
7
7
|
# (Cost::Base, Cost::Fixed, Cost::Variable, Cost::Compound, etc.)
|
|
8
8
|
class Numeric
|
|
9
9
|
def credits
|
|
10
|
-
|
|
11
|
-
raise ArgumentError, "Credit amount cannot be negative" if self.negative?
|
|
12
|
-
UsageCredits::Cost::Fixed.new(self.to_i)
|
|
10
|
+
UsageCredits::Cost::Fixed.new(self)
|
|
13
11
|
end
|
|
14
12
|
alias_method :credit, :credits
|
|
15
13
|
|
|
16
14
|
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
15
|
# Convert common units to their base unit
|
|
20
16
|
unit = case unit.to_s.downcase
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
17
|
+
when "mb", "megabyte", "megabytes"
|
|
18
|
+
:mb
|
|
19
|
+
when "kb", "kilobyte", "kilobytes"
|
|
20
|
+
:kb
|
|
21
|
+
when "gb", "gigabyte", "gigabytes"
|
|
22
|
+
:gb
|
|
23
|
+
when "unit", "units"
|
|
24
|
+
:units
|
|
25
|
+
else
|
|
26
|
+
unit.to_sym
|
|
27
|
+
end
|
|
32
28
|
|
|
33
29
|
UsageCredits::Cost::Variable.new(self, unit)
|
|
34
30
|
end
|
|
@@ -31,12 +31,7 @@ module UsageCredits
|
|
|
31
31
|
protected
|
|
32
32
|
|
|
33
33
|
def validate_amount!(amount)
|
|
34
|
-
|
|
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
|
|
34
|
+
CreditCalculator.normalize_credit_amount(amount)
|
|
40
35
|
end
|
|
41
36
|
end
|
|
42
37
|
end
|
|
@@ -12,8 +12,9 @@ module UsageCredits
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def calculate(params = {})
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# Components stay unrounded until Operation#calculate_cost applies the
|
|
16
|
+
# configured strategy once to the final sum.
|
|
17
|
+
costs.sum { |cost| cost.calculate(params) }
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def +(other)
|
|
@@ -7,7 +7,7 @@ module UsageCredits
|
|
|
7
7
|
attr_reader :period
|
|
8
8
|
|
|
9
9
|
def initialize(amount)
|
|
10
|
-
|
|
10
|
+
super
|
|
11
11
|
@period = nil # Will default to 1.month in CreditSubscriptionPlan
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -18,7 +18,6 @@ module UsageCredits
|
|
|
18
18
|
value.calculate(params)
|
|
19
19
|
else
|
|
20
20
|
validate_amount!(value)
|
|
21
|
-
value.to_i
|
|
22
21
|
end
|
|
23
22
|
end
|
|
24
23
|
|
|
@@ -4,37 +4,52 @@ module UsageCredits
|
|
|
4
4
|
module Cost
|
|
5
5
|
# Variable credit cost based on units (e.g., 1 credit per MB)
|
|
6
6
|
class Variable < Base
|
|
7
|
+
SUPPORTED_UNITS = %i[kb mb gb units].freeze
|
|
8
|
+
|
|
7
9
|
attr_reader :unit
|
|
8
10
|
|
|
9
11
|
def initialize(amount, unit)
|
|
10
12
|
super(amount)
|
|
11
13
|
@unit = unit.to_sym
|
|
14
|
+
raise ArgumentError, "Unknown unit: #{unit}" unless SUPPORTED_UNITS.include?(@unit)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def calculate(params = {})
|
|
18
|
+
# Return the raw composable cost. Operation#calculate_cost is the one
|
|
19
|
+
# rounding boundary, preventing nested Variable/Compound calculators
|
|
20
|
+
# from rounding the same business cost more than once.
|
|
15
21
|
size = extract_size(params)
|
|
16
|
-
|
|
17
|
-
CreditCalculator.apply_rounding(raw_cost)
|
|
22
|
+
amount * size
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
private
|
|
21
26
|
|
|
22
27
|
def extract_size(params)
|
|
23
28
|
case unit
|
|
24
|
-
when :mb
|
|
29
|
+
when :kb, :mb, :gb
|
|
25
30
|
# First check for direct MB value
|
|
26
31
|
if params[:mb]
|
|
27
|
-
|
|
32
|
+
convert_megabytes(params[:mb])
|
|
28
33
|
# Then check for bytes that need conversion
|
|
29
34
|
elsif params[:size]
|
|
30
|
-
|
|
35
|
+
params[:size].to_f / bytes_per_unit
|
|
31
36
|
else
|
|
32
37
|
0
|
|
33
38
|
end
|
|
34
39
|
when :units
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
params.fetch(:units, 0)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def convert_megabytes(megabytes)
|
|
45
|
+
(megabytes.to_f * 1.megabyte) / bytes_per_unit
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bytes_per_unit
|
|
49
|
+
case unit
|
|
50
|
+
when :kb then 1.kilobyte
|
|
51
|
+
when :mb then 1.megabyte
|
|
52
|
+
when :gb then 1.gigabyte
|
|
38
53
|
end
|
|
39
54
|
end
|
|
40
55
|
end
|
data/lib/usage_credits/engine.rb
CHANGED
|
@@ -5,16 +5,9 @@ module UsageCredits
|
|
|
5
5
|
class Engine < ::Rails::Engine
|
|
6
6
|
isolate_namespace UsageCredits
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
8
|
+
# All gem code is required eagerly by lib/usage_credits.rb. Adding these
|
|
9
|
+
# directories to the host app's autoloaders would make Zeitwerk claim
|
|
10
|
+
# common top-level constants such as ::Wallet and ::Operation.
|
|
18
11
|
|
|
19
12
|
# Add has_credits method to ActiveRecord::Base
|
|
20
13
|
initializer "usage_credits.active_record" do
|
|
@@ -30,8 +23,11 @@ module UsageCredits
|
|
|
30
23
|
end
|
|
31
24
|
end
|
|
32
25
|
|
|
33
|
-
initializer "usage_credits.
|
|
34
|
-
|
|
26
|
+
initializer "usage_credits.action_view" do
|
|
27
|
+
ActiveSupport.on_load :action_view do
|
|
28
|
+
require "usage_credits/helpers/credits_helper"
|
|
29
|
+
include UsageCredits::CreditsHelper
|
|
30
|
+
end
|
|
35
31
|
end
|
|
36
32
|
end
|
|
37
33
|
end
|
|
@@ -21,6 +21,24 @@ module UsageCredits
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Normalize every configured or dynamically calculated credit cost through
|
|
25
|
+
# one strict boundary. Keeping parse-error translation here means fixed
|
|
26
|
+
# values and Proc results cannot match Wallets error-message text in
|
|
27
|
+
# separate places and drift apart.
|
|
28
|
+
def normalize_credit_amount(amount)
|
|
29
|
+
number = begin
|
|
30
|
+
Wallets::WholeNumber.parse(amount, name: "Credit amount")
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
raise ArgumentError, "Credit amount must be a whole number (got: #{amount})"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if number.negative?
|
|
36
|
+
raise ArgumentError, "Credit amount cannot be negative (got: #{amount})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
number
|
|
40
|
+
end
|
|
41
|
+
|
|
24
42
|
# Convert a monetary amount to credits
|
|
25
43
|
def money_to_credits(cents, exchange_rate)
|
|
26
44
|
apply_rounding(cents * exchange_rate / 100.0)
|
|
@@ -17,20 +17,19 @@ module UsageCredits
|
|
|
17
17
|
# Credit pack purchase button
|
|
18
18
|
def credit_pack_button(pack, options = {})
|
|
19
19
|
button_to options[:path] || credit_pack_purchase_path(pack),
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
class: options[:class] || "credit-pack-button",
|
|
21
|
+
method: :post,
|
|
22
|
+
data: {
|
|
23
|
+
turbo: false,
|
|
24
|
+
pack_name: pack.name,
|
|
25
|
+
credits: pack.credits,
|
|
26
|
+
bonus_credits: pack.bonus_credits,
|
|
27
|
+
price: pack.price_cents
|
|
28
|
+
} do
|
|
29
29
|
render_credit_pack_button_content(pack)
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
34
33
|
private
|
|
35
34
|
|
|
36
35
|
def render_credit_pack_button_content(pack)
|
|
@@ -40,6 +39,5 @@ module UsageCredits
|
|
|
40
39
|
content_tag(:span, format_credit_price(pack.price_cents, pack.price_currency), class: "price")
|
|
41
40
|
].compact, " ")
|
|
42
41
|
end
|
|
43
|
-
|
|
44
42
|
end
|
|
45
43
|
end
|
|
@@ -4,7 +4,6 @@ module UsageCredits
|
|
|
4
4
|
# Handles parsing and normalization of time periods throughout the gem.
|
|
5
5
|
# Converts strings like "1.month" or symbols like :monthly into ActiveSupport::Duration objects.
|
|
6
6
|
module PeriodParser
|
|
7
|
-
|
|
8
7
|
# Canonical periods and their aliases
|
|
9
8
|
VALID_PERIODS = {
|
|
10
9
|
second: [:second, :seconds], # 1.second
|
|
@@ -17,6 +16,7 @@ module UsageCredits
|
|
|
17
16
|
year: [:year, :yearly, :annually] # 1.year
|
|
18
17
|
}.freeze
|
|
19
18
|
|
|
19
|
+
ABSOLUTE_MIN_PERIOD = 1.second
|
|
20
20
|
MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead
|
|
21
21
|
|
|
22
22
|
module_function
|
|
@@ -37,8 +37,7 @@ module UsageCredits
|
|
|
37
37
|
|
|
38
38
|
# Handle ActiveSupport::Duration objects directly
|
|
39
39
|
if period.is_a?(ActiveSupport::Duration)
|
|
40
|
-
|
|
41
|
-
raise ArgumentError, "Period must be at least #{min_period.inspect}" if period < min_period
|
|
40
|
+
validate_minimum!(period)
|
|
42
41
|
period
|
|
43
42
|
else
|
|
44
43
|
# Convert symbols to canonical durations
|
|
@@ -55,8 +54,7 @@ module UsageCredits
|
|
|
55
54
|
raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}"
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
|
|
57
|
+
validate_minimum!(duration)
|
|
60
58
|
duration
|
|
61
59
|
end
|
|
62
60
|
end
|
|
@@ -65,8 +63,11 @@ module UsageCredits
|
|
|
65
63
|
# @param period_str [String, ActiveSupport::Duration] A string like "1.month" or "1 month" or an existing duration
|
|
66
64
|
# @return [ActiveSupport::Duration] The parsed duration
|
|
67
65
|
# @raise [ArgumentError] If the period string is invalid
|
|
68
|
-
def parse_period(period_str)
|
|
69
|
-
|
|
66
|
+
def parse_period(period_str, enforce_minimum: true)
|
|
67
|
+
if period_str.is_a?(ActiveSupport::Duration)
|
|
68
|
+
validate_minimum!(period_str, persisted: !enforce_minimum)
|
|
69
|
+
return period_str
|
|
70
|
+
end
|
|
70
71
|
|
|
71
72
|
if period_str.to_s =~ /\A(\d+)[.\s](\w+)\z/
|
|
72
73
|
amount = $1.to_i
|
|
@@ -82,14 +83,19 @@ module UsageCredits
|
|
|
82
83
|
canonical_unit = canonical_unit_for(unit)
|
|
83
84
|
|
|
84
85
|
duration = amount.send(canonical_unit)
|
|
85
|
-
|
|
86
|
-
raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
|
|
86
|
+
validate_minimum!(duration, persisted: !enforce_minimum)
|
|
87
87
|
duration
|
|
88
88
|
else
|
|
89
89
|
raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc."
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Persisted commercial schedules remain valid if an operator later raises
|
|
94
|
+
# the minimum allowed for newly configured plans.
|
|
95
|
+
def parse_persisted_period(period_str)
|
|
96
|
+
parse_period(period_str, enforce_minimum: false)
|
|
97
|
+
end
|
|
98
|
+
|
|
93
99
|
# Map any alias to its canonical unit method name
|
|
94
100
|
# @param unit [Symbol] The unit symbol (e.g., :hourly, :seconds, :day)
|
|
95
101
|
# @return [Symbol] The canonical unit method (e.g., :hour, :second, :day)
|
|
@@ -111,5 +117,21 @@ module UsageCredits
|
|
|
111
117
|
false
|
|
112
118
|
end
|
|
113
119
|
|
|
120
|
+
def valid_persisted_period_format?(period_str)
|
|
121
|
+
parse_persisted_period(period_str)
|
|
122
|
+
true
|
|
123
|
+
rescue ArgumentError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_minimum!(duration, persisted: false)
|
|
128
|
+
# Persisted commercial terms must survive a later operator-configured
|
|
129
|
+
# minimum increase, but they can never bypass the gem's hard safety
|
|
130
|
+
# floor. A zero cadence remains perpetually due and can mint on every job
|
|
131
|
+
# run, so one second is the absolute minimum for all schedules.
|
|
132
|
+
min_period = persisted ? ABSOLUTE_MIN_PERIOD : min_fulfillment_period
|
|
133
|
+
raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
|
|
134
|
+
end
|
|
135
|
+
private_class_method :validate_minimum!
|
|
114
136
|
end
|
|
115
137
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UsageCredits
|
|
4
|
+
# Normalizes and validates metadata before it crosses a payment-processor
|
|
5
|
+
# boundary. Stripe stores metadata as at most 50 string key/value pairs;
|
|
6
|
+
# structured Ruby values are encoded as one JSON string instead of becoming
|
|
7
|
+
# unsupported nested form parameters.
|
|
8
|
+
module ProcessorMetadata
|
|
9
|
+
MAX_PAIRS = 50
|
|
10
|
+
MAX_KEY_LENGTH = 40
|
|
11
|
+
MAX_VALUE_LENGTH = 500
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def normalize(value)
|
|
16
|
+
hash = value.nil? ? {} : value.to_h
|
|
17
|
+
raise ArgumentError, "Processor metadata cannot contain more than #{MAX_PAIRS} keys" if hash.size > MAX_PAIRS
|
|
18
|
+
|
|
19
|
+
hash.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(key, raw_value), normalized|
|
|
20
|
+
normalized_key = key.to_s
|
|
21
|
+
validate_key!(normalized_key)
|
|
22
|
+
|
|
23
|
+
normalized_value = structured?(raw_value) ? ActiveSupport::JSON.encode(raw_value) : raw_value.to_s
|
|
24
|
+
if normalized_value.length > MAX_VALUE_LENGTH
|
|
25
|
+
raise ArgumentError, "Processor metadata value for #{normalized_key.inspect} exceeds #{MAX_VALUE_LENGTH} characters"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
normalized[normalized_key] = normalized_value
|
|
29
|
+
end
|
|
30
|
+
rescue NoMethodError, TypeError
|
|
31
|
+
raise ArgumentError, "Processor metadata must be hash-like"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_key!(key)
|
|
35
|
+
if key.empty? || key.length > MAX_KEY_LENGTH || key.match?(/[\[\]]/)
|
|
36
|
+
raise ArgumentError, "Invalid processor metadata key: #{key.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
private_class_method :validate_key!
|
|
40
|
+
|
|
41
|
+
def structured?(value)
|
|
42
|
+
value.is_a?(Hash) || value.is_a?(Array)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :structured?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -17,7 +17,7 @@ module UsageCredits
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
Rails.logger.info "Completed processing #{count} fulfillments in #{formatted_time}"
|
|
20
|
-
rescue
|
|
20
|
+
rescue => e
|
|
21
21
|
Rails.logger.error "Error processing credit fulfillments: #{e.message}"
|
|
22
22
|
raise # Re-raise to trigger job retry
|
|
23
23
|
end
|
|
@@ -5,29 +5,24 @@ module UsageCredits
|
|
|
5
5
|
# to a *positive* (credit) transaction, indicating how many
|
|
6
6
|
# credits were taken from that specific credit source.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
# This is critical for calculating balances when there are mixed expiring and non-expiring credits
|
|
10
|
-
# Otherwise, balance calculations will always be wrong because negative transactions get dragged forever
|
|
11
|
-
# More info: https://x.com/rameerez/status/1884246492837302759
|
|
12
|
-
class Allocation < ApplicationRecord
|
|
13
|
-
self.table_name = "usage_credits_allocations"
|
|
8
|
+
# This class extends Wallets::Allocation with usage_credits table configuration.
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
class Allocation < Wallets::AllocationBase
|
|
11
|
+
# =========================================
|
|
12
|
+
# Embeddability Configuration
|
|
13
|
+
# =========================================
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
self.embedded_table_name = "usage_credits_allocations"
|
|
16
|
+
self.config_provider = -> { UsageCredits.configuration }
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def allocation_does_not_exceed_remaining_amount
|
|
25
|
-
return if amount.blank? || source_transaction.blank?
|
|
26
|
-
|
|
27
|
-
if source_transaction.remaining_amount < amount
|
|
28
|
-
errors.add(:amount, "exceeds the remaining amount of the source transaction")
|
|
29
|
-
end
|
|
30
|
-
end
|
|
18
|
+
# =========================================
|
|
19
|
+
# Re-declare Associations with Correct Classes
|
|
20
|
+
# =========================================
|
|
31
21
|
|
|
22
|
+
belongs_to :spend_transaction,
|
|
23
|
+
class_name: "UsageCredits::Transaction",
|
|
24
|
+
foreign_key: "transaction_id",
|
|
25
|
+
optional: false
|
|
26
|
+
belongs_to :source_transaction, class_name: "UsageCredits::Transaction", optional: false
|
|
32
27
|
end
|
|
33
28
|
end
|