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.
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