usage_credits 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +559 -0
  6. data/Rakefile +32 -0
  7. data/lib/generators/usage_credits/install_generator.rb +49 -0
  8. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
  9. data/lib/generators/usage_credits/templates/initializer.rb +105 -0
  10. data/lib/usage_credits/configuration.rb +204 -0
  11. data/lib/usage_credits/core_ext/numeric.rb +59 -0
  12. data/lib/usage_credits/cost/base.rb +43 -0
  13. data/lib/usage_credits/cost/compound.rb +37 -0
  14. data/lib/usage_credits/cost/fixed.rb +34 -0
  15. data/lib/usage_credits/cost/variable.rb +42 -0
  16. data/lib/usage_credits/engine.rb +37 -0
  17. data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
  18. data/lib/usage_credits/helpers/credits_helper.rb +45 -0
  19. data/lib/usage_credits/helpers/period_parser.rb +77 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
  21. data/lib/usage_credits/models/allocation.rb +31 -0
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
  25. data/lib/usage_credits/models/credit_pack.rb +159 -0
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
  27. data/lib/usage_credits/models/fulfillment.rb +91 -0
  28. data/lib/usage_credits/models/operation.rb +153 -0
  29. data/lib/usage_credits/models/transaction.rb +174 -0
  30. data/lib/usage_credits/models/wallet.rb +310 -0
  31. data/lib/usage_credits/railtie.rb +17 -0
  32. data/lib/usage_credits/services/fulfillment_service.rb +129 -0
  33. data/lib/usage_credits/version.rb +5 -0
  34. data/lib/usage_credits.rb +170 -0
  35. data/sig/usagecredits.rbs +4 -0
  36. 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