usage_credits 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|