Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/generators/usage_credits/templates/initializer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# frozen_string_literal: true

UsageCredits.configure do |config|
#
# Minimum fulfillment period for subscription plans (default: 1.day)
# In development/test, you can set this to a shorter period for faster testing:
#
# config.min_fulfillment_period = 2.seconds if Rails.env.development?
#
#
#
# Define your credit-consuming operations below
#
Expand Down Expand Up @@ -85,7 +92,7 @@
# During this time, old credits from the previous period will erroneously count as available balance.
# But if we set this to 0 or nil, user balance will show up as zero some time until the next fulfillment cycle hits.
# A good default is to match the frequency of your UsageCredits::FulfillmentJob
# fulfillment_grace_period = 5.minutes
# config.fulfillment_grace_period = 5.minutes
#
#
#
Expand Down
20 changes: 20 additions & 0 deletions lib/usage_credits/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ class Configuration

attr_reader :fulfillment_grace_period

# Minimum allowed fulfillment period for subscription plans.
# Defaults to 1.day to prevent accidental 1-second refill loops in production.
# Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
attr_reader :min_fulfillment_period

# =========================================
# Low balance
# =========================================
Expand Down Expand Up @@ -60,6 +65,9 @@ def initialize
# A good setting is to match the frequency of your UsageCredits::FulfillmentJob runs
@fulfillment_grace_period = 5.minutes # If you run your fulfillment job every 5 minutes, this should be enough

# Minimum fulfillment period - prevents accidental 1-second refill loops in production
@min_fulfillment_period = 1.day

@allow_negative_balance = false
@low_balance_threshold = nil
@low_balance_callback = nil # Called when user hits low_balance_threshold
Expand Down Expand Up @@ -153,6 +161,18 @@ def fulfillment_grace_period=(value)
@fulfillment_grace_period = value
end

def min_fulfillment_period=(value)
unless value.is_a?(ActiveSupport::Duration)
raise ArgumentError, "Minimum fulfillment period must be an ActiveSupport::Duration (e.g. 1.day, 2.seconds)"
end

if value < 1.second
raise ArgumentError, "Minimum fulfillment period must be at least 1 second"
end

@min_fulfillment_period = value
end

# =========================================
# Callback & Formatter Configuration
# =========================================
Expand Down
48 changes: 43 additions & 5 deletions lib/usage_credits/helpers/period_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,45 @@ module PeriodParser

# Canonical periods and their aliases
VALID_PERIODS = {
second: [:second, :seconds], # 1.second
minute: [:minute, :minutes], # 1.minute
hour: [:hour, :hours, :hourly], # 1.hour
day: [:day, :daily], # 1.day
week: [:week, :weekly], # 1.week
month: [:month, :monthly], # 1.month
quarter: [:quarter, :quarterly], # 3.months
year: [:year, :yearly, :annually] # 1.year
}.freeze

MIN_PERIOD = 1.day
MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead

module_function

# Get the configured minimum fulfillment period
def min_fulfillment_period
# Use configured value if available, otherwise fall back to default
if defined?(UsageCredits) && UsageCredits.respond_to?(:configuration)
UsageCredits.configuration.min_fulfillment_period
else
MIN_PERIOD
end
end

# Turns things like `:monthly` into `1.month` to always store consistent time periods
def normalize_period(period)
return nil unless period

# Handle ActiveSupport::Duration objects directly
if period.is_a?(ActiveSupport::Duration)
raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if period < MIN_PERIOD
min_period = min_fulfillment_period
raise ArgumentError, "Period must be at least #{min_period.inspect}" if period < min_period
period
else
# Convert symbols to canonical durations
case period
duration = case period
when *VALID_PERIODS[:second] then 1.second
when *VALID_PERIODS[:minute] then 1.minute
when *VALID_PERIODS[:hour] then 1.hour
when *VALID_PERIODS[:day] then 1.day
when *VALID_PERIODS[:week] then 1.week
when *VALID_PERIODS[:month] then 1.month
Expand All @@ -37,6 +54,10 @@ def normalize_period(period)
else
raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}"
end

min_period = min_fulfillment_period
raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
duration
end
end

Expand All @@ -57,14 +78,31 @@ def parse_period(period_str)
raise ArgumentError, "Unsupported period unit: #{unit}. Supported units: #{valid_units.inspect}"
end

duration = amount.send(unit)
raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if duration < MIN_PERIOD
# Map alias to canonical unit (e.g., :hourly -> :hour, :seconds -> :second)
canonical_unit = canonical_unit_for(unit)

duration = amount.send(canonical_unit)
min_period = min_fulfillment_period
raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
duration
else
raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc."
end
end

# Map any alias to its canonical unit method name
# @param unit [Symbol] The unit symbol (e.g., :hourly, :seconds, :day)
# @return [Symbol] The canonical unit method (e.g., :hour, :second, :day)
def canonical_unit_for(unit)
# Find which canonical unit this alias belongs to
VALID_PERIODS.each do |canonical, aliases|
return canonical if aliases.include?(unit)
end

# Fallback to the unit itself if not found (shouldn't happen if validation passed)
unit
end

# Validates that a period string matches the expected format and units
def valid_period_format?(period_str)
parse_period(period_str)
Expand Down
2 changes: 1 addition & 1 deletion lib/usage_credits/models/credit_subscription_plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class CreditSubscriptionPlan

attr_writer :fulfillment_period

MIN_PERIOD = 1.day
MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead

def initialize(name)
@name = name
Expand Down
Loading