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.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +32 -0
- data/lib/generators/usage_credits/install_generator.rb +49 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
- data/lib/generators/usage_credits/templates/initializer.rb +105 -0
- data/lib/usage_credits/configuration.rb +204 -0
- data/lib/usage_credits/core_ext/numeric.rb +59 -0
- data/lib/usage_credits/cost/base.rb +43 -0
- data/lib/usage_credits/cost/compound.rb +37 -0
- data/lib/usage_credits/cost/fixed.rb +34 -0
- data/lib/usage_credits/cost/variable.rb +42 -0
- data/lib/usage_credits/engine.rb +37 -0
- data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
- data/lib/usage_credits/helpers/credits_helper.rb +45 -0
- data/lib/usage_credits/helpers/period_parser.rb +77 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
- data/lib/usage_credits/models/allocation.rb +31 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
- data/lib/usage_credits/models/credit_pack.rb +159 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
- data/lib/usage_credits/models/fulfillment.rb +91 -0
- data/lib/usage_credits/models/operation.rb +153 -0
- data/lib/usage_credits/models/transaction.rb +174 -0
- data/lib/usage_credits/models/wallet.rb +310 -0
- data/lib/usage_credits/railtie.rb +17 -0
- data/lib/usage_credits/services/fulfillment_service.rb +129 -0
- data/lib/usage_credits/version.rb +5 -0
- data/lib/usage_credits.rb +170 -0
- data/sig/usagecredits.rbs +4 -0
- metadata +115 -0
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# A Fulfillment represents a credit-giving action triggered by a purchase,
|
5
|
+
# including credit pack purchases and subscriptions.
|
6
|
+
# Some of this credit-giving actions are repeating in nature (i.e.: subscriptions), some are not (one-time purchases)
|
7
|
+
class Fulfillment < ApplicationRecord
|
8
|
+
self.table_name = "usage_credits_fulfillments"
|
9
|
+
|
10
|
+
belongs_to :wallet
|
11
|
+
belongs_to :source, polymorphic: true, optional: true
|
12
|
+
|
13
|
+
validates :wallet, presence: true
|
14
|
+
validates :source_id, uniqueness: { scope: :source_type }, if: :source_id?
|
15
|
+
validates :credits_last_fulfillment, presence: true, numericality: { only_integer: true }
|
16
|
+
validates :fulfillment_type, presence: true
|
17
|
+
validate :valid_fulfillment_period_format, if: :fulfillment_period?
|
18
|
+
validates :next_fulfillment_at, comparison: { greater_than: :last_fulfilled_at },
|
19
|
+
if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? }
|
20
|
+
|
21
|
+
# Only get fulfillments that are due AND not stopped
|
22
|
+
scope :due_for_fulfillment, -> {
|
23
|
+
where("next_fulfillment_at <= ?", Time.current)
|
24
|
+
.where("stops_at IS NULL OR stops_at > ?", Time.current)
|
25
|
+
.where("last_fulfilled_at IS NULL OR next_fulfillment_at > last_fulfilled_at")
|
26
|
+
}
|
27
|
+
scope :active, -> { where("stops_at IS NULL OR stops_at > ?", Time.current) }
|
28
|
+
|
29
|
+
# Alias for backward compatibility - will be removed in next version
|
30
|
+
scope :pending, -> { due_for_fulfillment }
|
31
|
+
|
32
|
+
def due_for_fulfillment?
|
33
|
+
return false unless next_fulfillment_at.present?
|
34
|
+
return false if stopped?
|
35
|
+
return false if last_fulfilled_at.present? && next_fulfillment_at <= last_fulfilled_at
|
36
|
+
|
37
|
+
next_fulfillment_at <= Time.current
|
38
|
+
end
|
39
|
+
|
40
|
+
def recurring?
|
41
|
+
fulfillment_period.present?
|
42
|
+
end
|
43
|
+
|
44
|
+
def stopped?
|
45
|
+
stops_at.present? && stops_at <= Time.current
|
46
|
+
end
|
47
|
+
|
48
|
+
def active?
|
49
|
+
!stopped?
|
50
|
+
end
|
51
|
+
|
52
|
+
def calculate_next_fulfillment
|
53
|
+
return nil unless recurring?
|
54
|
+
return nil if stopped?
|
55
|
+
return nil if next_fulfillment_at.nil?
|
56
|
+
|
57
|
+
# If next_fulfillment_at is in the past (e.g. due to missed fulfillments or errors),
|
58
|
+
# we use current time as the base to avoid scheduling multiple rapid fulfillments.
|
59
|
+
# This ensures smooth recovery from missed fulfillments by scheduling the next one
|
60
|
+
# from the current time rather than the missed fulfillment time.
|
61
|
+
base_time = next_fulfillment_at > Time.current ? next_fulfillment_at : Time.current
|
62
|
+
|
63
|
+
base_time + UsageCredits::PeriodParser.parse_period(fulfillment_period)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def valid_fulfillment_period_format
|
69
|
+
unless UsageCredits::PeriodParser.valid_period_format?(fulfillment_period)
|
70
|
+
errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units")
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
validate :validate_fulfillment_schedule
|
75
|
+
|
76
|
+
def validate_fulfillment_schedule
|
77
|
+
return unless next_fulfillment_at.present?
|
78
|
+
|
79
|
+
if recurring?
|
80
|
+
# For recurring fulfillments, next_fulfillment_at should be in the future when created
|
81
|
+
if new_record? && next_fulfillment_at <= Time.current
|
82
|
+
errors.add(:next_fulfillment_at, "must be in the future for new recurring fulfillments")
|
83
|
+
end
|
84
|
+
else
|
85
|
+
# For one-time fulfillments, next_fulfillment_at should be nil
|
86
|
+
errors.add(:next_fulfillment_at, "should be nil for non-recurring fulfillments") unless new_record?
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# A DSL to define an operation that consumes credits when performed.
|
5
|
+
class Operation
|
6
|
+
|
7
|
+
attr_reader :name, # Operation identifier (e.g., :process_video)
|
8
|
+
:cost_calculator, # Lambda or Fixed that calculates credit cost
|
9
|
+
:validation_rules, # Array of [condition, message] pairs
|
10
|
+
:metadata # Custom data for your app's use
|
11
|
+
|
12
|
+
def initialize(name, &block)
|
13
|
+
@name = name
|
14
|
+
@cost_calculator = Cost::Fixed.new(0) # Default to free
|
15
|
+
@validation_rules = []
|
16
|
+
@metadata = {}
|
17
|
+
instance_eval(&block) if block_given?
|
18
|
+
end
|
19
|
+
|
20
|
+
# =========================================
|
21
|
+
# DSL Methods (used in initializer blocks)
|
22
|
+
# =========================================
|
23
|
+
|
24
|
+
# Set how many credits this operation costs
|
25
|
+
#
|
26
|
+
# @param amount_or_calculator [Integer, Lambda] Fixed amount or dynamic calculator
|
27
|
+
# costs 10 # Fixed cost
|
28
|
+
# costs ->(params) { params[:mb] } # Dynamic cost
|
29
|
+
def costs(amount_or_calculator)
|
30
|
+
@cost_calculator = case amount_or_calculator
|
31
|
+
when Cost::Base
|
32
|
+
amount_or_calculator
|
33
|
+
else
|
34
|
+
Cost::Fixed.new(amount_or_calculator)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
alias_method :cost, :costs
|
38
|
+
|
39
|
+
# Add a validation rule for this operation
|
40
|
+
# Example: can't process images bigger than 100MB
|
41
|
+
#
|
42
|
+
# @param condition [Lambda] Returns true if valid
|
43
|
+
# @param message [String] Error message if invalid
|
44
|
+
def validate(condition, message = nil)
|
45
|
+
@validation_rules << [condition, message || "Operation validation failed"]
|
46
|
+
end
|
47
|
+
|
48
|
+
# Add custom metadata
|
49
|
+
def meta(hash)
|
50
|
+
@metadata = @metadata.merge(hash.transform_keys(&:to_s))
|
51
|
+
end
|
52
|
+
|
53
|
+
# =========================================
|
54
|
+
# Cost Calculation
|
55
|
+
# =========================================
|
56
|
+
|
57
|
+
# Calculate how many credits this operation will cost
|
58
|
+
# @param params [Hash] Operation parameters (e.g., file size)
|
59
|
+
# @return [Integer] Number of credits
|
60
|
+
def calculate_cost(params = {})
|
61
|
+
normalized_params = normalize_params(params)
|
62
|
+
validate!(normalized_params) # Ensure params are valid before calculating
|
63
|
+
|
64
|
+
# Calculate raw cost
|
65
|
+
total = case cost_calculator
|
66
|
+
when Proc
|
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
|
74
|
+
|
75
|
+
# Apply configured rounding strategy
|
76
|
+
CreditCalculator.apply_rounding(total)
|
77
|
+
end
|
78
|
+
|
79
|
+
# =========================================
|
80
|
+
# Validation
|
81
|
+
# =========================================
|
82
|
+
|
83
|
+
# Check if the operation can be performed
|
84
|
+
# @param params [Hash] Operation parameters to validate
|
85
|
+
# @raise [InvalidOperation] If validation fails
|
86
|
+
def validate!(params = {})
|
87
|
+
normalized = normalize_params(params)
|
88
|
+
|
89
|
+
validation_rules.each do |condition, message|
|
90
|
+
next unless condition.is_a?(Proc)
|
91
|
+
|
92
|
+
begin
|
93
|
+
result = condition.call(normalized)
|
94
|
+
raise InvalidOperation, message unless result
|
95
|
+
rescue StandardError => e
|
96
|
+
raise InvalidOperation, "Validation error: #{e.message}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# =========================================
|
102
|
+
# Audit Trail
|
103
|
+
# =========================================
|
104
|
+
|
105
|
+
# Create an audit record of this operation
|
106
|
+
def to_audit_hash(params = {})
|
107
|
+
{
|
108
|
+
operation: name,
|
109
|
+
cost: calculate_cost(params),
|
110
|
+
params: params,
|
111
|
+
metadata: metadata,
|
112
|
+
executed_at: Time.current,
|
113
|
+
gem_version: UsageCredits::VERSION
|
114
|
+
}
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
# =========================================
|
120
|
+
# Parameter Handling
|
121
|
+
# =========================================
|
122
|
+
|
123
|
+
# Normalize different parameter formats
|
124
|
+
# - Handles different size units (MB, bytes)
|
125
|
+
# - Handles different parameter names
|
126
|
+
def normalize_params(params)
|
127
|
+
params = params.symbolize_keys
|
128
|
+
|
129
|
+
# Handle different size specifications
|
130
|
+
size = if params[:mb]
|
131
|
+
params[:mb].to_f
|
132
|
+
elsif params[:size_mb]
|
133
|
+
params[:size_mb].to_f
|
134
|
+
elsif params[:size_megabytes]
|
135
|
+
params[:size_megabytes].to_f
|
136
|
+
elsif params[:size]
|
137
|
+
params[:size].to_f / 1.megabyte
|
138
|
+
else
|
139
|
+
0.0
|
140
|
+
end
|
141
|
+
|
142
|
+
# Handle generic unit-based operations
|
143
|
+
units = params[:units].to_f if params[:units]
|
144
|
+
|
145
|
+
params.merge(
|
146
|
+
size: (size * 1.megabyte).to_i, # Raw bytes
|
147
|
+
mb: size, # MB for convenience
|
148
|
+
units: units || 0.0 # Generic units
|
149
|
+
)
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Records all credit changes in a wallet (additions, deductions, expirations).
|
5
|
+
#
|
6
|
+
# Each transaction represents a single credit operation and includes:
|
7
|
+
# - amount: How many credits (positive for additions, negative for deductions)
|
8
|
+
# - category: What kind of operation (subscription fulfillment, pack purchase, etc)
|
9
|
+
# - metadata: Additional details about the operation
|
10
|
+
# - expires_at: When these credits expire (optional)
|
11
|
+
class Transaction < ApplicationRecord
|
12
|
+
self.table_name = "usage_credits_transactions"
|
13
|
+
|
14
|
+
# =========================================
|
15
|
+
# Transaction Categories
|
16
|
+
# =========================================
|
17
|
+
|
18
|
+
# All possible transaction types, grouped by purpose:
|
19
|
+
CATEGORIES = [
|
20
|
+
# Bonus credits
|
21
|
+
"signup_bonus", # Initial signup bonus
|
22
|
+
"referral_bonus", # Referral reward
|
23
|
+
|
24
|
+
# Subscription-related
|
25
|
+
"subscription_credits", # Generic subscription credits
|
26
|
+
"subscription_trial", # Trial period credits
|
27
|
+
"subscription_signup_bonus", # Bonus for subscribing
|
28
|
+
|
29
|
+
# One-time purchases
|
30
|
+
"credit_pack", # Generic credit pack
|
31
|
+
"credit_pack_purchase", # Credit pack bought
|
32
|
+
"credit_pack_refund", # Credit pack refunded
|
33
|
+
|
34
|
+
# Credit usage & management
|
35
|
+
"operation_charge", # Credits spent on operation
|
36
|
+
"manual_adjustment", # Manual admin adjustment
|
37
|
+
"credit_added", # Generic addition
|
38
|
+
"credit_deducted" # Generic deduction
|
39
|
+
].freeze
|
40
|
+
|
41
|
+
# =========================================
|
42
|
+
# Associations & Validations
|
43
|
+
# =========================================
|
44
|
+
|
45
|
+
belongs_to :wallet
|
46
|
+
|
47
|
+
belongs_to :fulfillment, optional: true
|
48
|
+
|
49
|
+
has_many :outgoing_allocations,
|
50
|
+
class_name: "UsageCredits::Allocation",
|
51
|
+
foreign_key: :transaction_id,
|
52
|
+
dependent: :destroy
|
53
|
+
|
54
|
+
has_many :incoming_allocations,
|
55
|
+
class_name: "UsageCredits::Allocation",
|
56
|
+
foreign_key: :source_transaction_id,
|
57
|
+
dependent: :destroy
|
58
|
+
|
59
|
+
validates :amount, presence: true, numericality: { only_integer: true }
|
60
|
+
validates :category, presence: true, inclusion: { in: CATEGORIES }
|
61
|
+
|
62
|
+
validate :remaining_amount_cannot_be_negative
|
63
|
+
|
64
|
+
# =========================================
|
65
|
+
# Scopes
|
66
|
+
# =========================================
|
67
|
+
|
68
|
+
scope :credits_added, -> { where("amount > 0") }
|
69
|
+
scope :credits_deducted, -> { where("amount < 0") }
|
70
|
+
scope :by_category, ->(category) { where(category: category) }
|
71
|
+
scope :recent, -> { order(created_at: :desc) }
|
72
|
+
scope :operation_charges, -> { where(category: :operation_charge) }
|
73
|
+
|
74
|
+
# A transaction is not expired if:
|
75
|
+
# 1. It has no expiration date, OR
|
76
|
+
# 2. Its expiration date is in the future
|
77
|
+
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
78
|
+
scope :expired, -> { where("expires_at < ?", Time.current) }
|
79
|
+
|
80
|
+
|
81
|
+
# =========================================
|
82
|
+
# Helpers
|
83
|
+
# =========================================
|
84
|
+
|
85
|
+
# Get the owner of the wallet these credits belong to
|
86
|
+
def owner
|
87
|
+
wallet.owner
|
88
|
+
end
|
89
|
+
|
90
|
+
# Have these credits expired?
|
91
|
+
def expired?
|
92
|
+
expires_at.present? && expires_at < Time.current
|
93
|
+
end
|
94
|
+
|
95
|
+
# Is this transaction a positive credit or a negative (spend)?
|
96
|
+
def credit?
|
97
|
+
amount > 0
|
98
|
+
end
|
99
|
+
|
100
|
+
def debit?
|
101
|
+
amount < 0
|
102
|
+
end
|
103
|
+
|
104
|
+
# How many credits from this transaction have already been allocated (spent)?
|
105
|
+
# Only applies if this transaction is positive.
|
106
|
+
def allocated_amount
|
107
|
+
incoming_allocations.sum(:amount)
|
108
|
+
end
|
109
|
+
|
110
|
+
# How many credits remain unused in this positive transaction?
|
111
|
+
# If negative, this will effectively be 0.
|
112
|
+
def remaining_amount
|
113
|
+
return 0 unless credit?
|
114
|
+
amount - allocated_amount
|
115
|
+
end
|
116
|
+
|
117
|
+
# =========================================
|
118
|
+
# Display Formatting
|
119
|
+
# =========================================
|
120
|
+
|
121
|
+
# Format the amount for display (e.g., "+100 credits" or "-10 credits")
|
122
|
+
def formatted_amount
|
123
|
+
prefix = amount.positive? ? "+" : ""
|
124
|
+
"#{prefix}#{UsageCredits.configuration.credit_formatter.call(amount)}"
|
125
|
+
end
|
126
|
+
|
127
|
+
# Get a human-readable description of what this transaction represents
|
128
|
+
def description
|
129
|
+
# Custom description takes precedence
|
130
|
+
return self[:description] if self[:description].present?
|
131
|
+
|
132
|
+
# Operation charges have dynamic descriptions
|
133
|
+
return operation_description if category == "operation_charge"
|
134
|
+
|
135
|
+
# Use predefined description or fallback to titleized category
|
136
|
+
category.titleize
|
137
|
+
end
|
138
|
+
|
139
|
+
# =========================================
|
140
|
+
# Metadata Handling
|
141
|
+
# =========================================
|
142
|
+
|
143
|
+
# Get metadata with indifferent access (string/symbol keys)
|
144
|
+
def metadata
|
145
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
146
|
+
end
|
147
|
+
|
148
|
+
# Set metadata, ensuring consistent storage format
|
149
|
+
def metadata=(hash)
|
150
|
+
@indifferent_metadata = nil # Clear cache
|
151
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
# Format operation charge descriptions (e.g., "Process Video (-10 credits)")
|
157
|
+
def operation_description
|
158
|
+
operation = metadata["operation"]&.to_s&.titleize
|
159
|
+
cost = metadata["cost"]
|
160
|
+
|
161
|
+
return "Operation charge" if operation.blank?
|
162
|
+
return operation if cost.blank?
|
163
|
+
|
164
|
+
"#{operation} (-#{cost} credits)"
|
165
|
+
end
|
166
|
+
|
167
|
+
def remaining_amount_cannot_be_negative
|
168
|
+
if credit? && remaining_amount < 0
|
169
|
+
errors.add(:base, "Allocated amount exceeds transaction amount")
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
end
|
174
|
+
end
|