usage_credits 0.5.0 → 1.0.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +120 -29
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
  6. data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
  7. data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
  8. data/lib/usage_credits/callbacks.rb +9 -54
  9. data/lib/usage_credits/configuration.rb +25 -13
  10. data/lib/usage_credits/core_ext/numeric.rb +12 -16
  11. data/lib/usage_credits/cost/base.rb +1 -6
  12. data/lib/usage_credits/cost/compound.rb +3 -2
  13. data/lib/usage_credits/cost/fixed.rb +1 -2
  14. data/lib/usage_credits/cost/variable.rb +23 -8
  15. data/lib/usage_credits/engine.rb +8 -12
  16. data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
  17. data/lib/usage_credits/helpers/credits_helper.rb +9 -11
  18. data/lib/usage_credits/helpers/period_parser.rb +31 -9
  19. data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
  21. data/lib/usage_credits/models/allocation.rb +15 -20
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
  25. data/lib/usage_credits/models/credit_pack.rb +34 -13
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
  27. data/lib/usage_credits/models/fulfillment.rb +23 -48
  28. data/lib/usage_credits/models/operation.rb +36 -28
  29. data/lib/usage_credits/models/transaction.rb +51 -158
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +219 -314
  32. data/lib/usage_credits/railtie.rb +4 -13
  33. data/lib/usage_credits/services/fulfillment_service.rb +146 -22
  34. data/lib/usage_credits/subscription_terms.rb +131 -0
  35. data/lib/usage_credits/version.rb +1 -1
  36. data/lib/usage_credits.rb +16 -3
  37. metadata +33 -20
  38. data/.rubocop.yml +0 -8
  39. data/.simplecov +0 -37
  40. data/AGENTS.md +0 -5
  41. data/Appraisals +0 -43
  42. data/CLAUDE.md +0 -5
  43. data/Rakefile +0 -32
  44. data/gemfiles/pay_10.0.gemfile +0 -41
  45. data/gemfiles/pay_11.0.gemfile +0 -41
  46. data/gemfiles/pay_8.3.gemfile +0 -41
  47. data/gemfiles/pay_9.0.gemfile +0 -41
  48. data/gemfiles/rails_7.2.gemfile +0 -41
  49. data/gemfiles/rails_8.1.gemfile +0 -41
@@ -3,146 +3,88 @@
3
3
  module UsageCredits
4
4
  # Records all credit changes in a wallet (additions, deductions, expirations).
5
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"
6
+ # This class extends Wallets::Transaction with usage_credits-specific features:
7
+ # - Fulfillment tracking for subscription/pack credits
8
+ # - Usage-credits specific transaction categories
9
+ # - Operation charge descriptions and formatting
10
+
11
+ class Transaction < Wallets::TransactionBase
12
+ # =========================================
13
+ # Embeddability Configuration
14
+ # =========================================
15
+
16
+ self.embedded_table_name = "usage_credits_transactions"
17
+ self.config_provider = -> { UsageCredits.configuration }
13
18
 
14
19
  # =========================================
15
20
  # Transaction Categories
16
21
  # =========================================
17
22
 
18
- # Default transaction types, grouped by purpose:
23
+ # Override base categories with usage_credits-specific ones
19
24
  DEFAULT_CATEGORIES = [
20
25
  # Bonus credits
21
- "signup_bonus", # Initial signup bonus
22
- "referral_bonus", # Referral reward bonus
23
- "bonus", # Generic bonus
26
+ "signup_bonus",
27
+ "referral_bonus",
28
+ "bonus",
24
29
 
25
30
  # Subscription-related
26
- "subscription_credits", # Generic subscription credits
27
- "subscription_trial", # Trial period credits
28
- "subscription_signup_bonus", # Bonus for subscribing
29
- "subscription_upgrade", # Plan upgrade credits
31
+ "subscription_credits",
32
+ "subscription_trial",
33
+ "subscription_signup_bonus",
34
+ "subscription_upgrade",
30
35
 
31
36
  # One-time purchases
32
- "credit_pack", # Generic credit pack
33
- "credit_pack_purchase", # Credit pack bought
34
- "credit_pack_refund", # Credit pack refunded
37
+ "credit_pack",
38
+ "credit_pack_purchase",
39
+ "credit_pack_refund",
35
40
 
36
41
  # Credit usage & management
37
- "operation_charge", # Credits spent on operation
38
- "manual_adjustment", # Manual admin adjustment
39
- "credit_added", # Generic addition
40
- "credit_deducted" # Generic deduction
42
+ "operation_charge",
43
+ "manual_adjustment",
44
+ "credit_added",
45
+ "credit_deducted",
46
+
47
+ # Transfer categories (from wallets)
48
+ "transfer_in",
49
+ "transfer_out"
41
50
  ].freeze
42
51
 
43
- # All valid categories: defaults + any custom categories added via config
44
- # @return [Array<String>] Combined list of valid category names
52
+ # Kept as an immutable compatibility snapshot for callers that referenced
53
+ # the historical constant. Runtime validation uses .categories so configured
54
+ # additions remain dynamic without mutating this public constant.
55
+ CATEGORIES = DEFAULT_CATEGORIES
56
+
45
57
  def self.categories
46
- (DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq
58
+ (DEFAULT_CATEGORIES + resolved_config.additional_categories).uniq
47
59
  end
48
60
 
49
- # Backwards compatibility: CATEGORIES constant still works
50
- # but prefer using Transaction.categories for dynamic lookup
51
- CATEGORIES = DEFAULT_CATEGORIES
52
-
53
61
  # =========================================
54
- # Associations & Validations
62
+ # Additional Associations
55
63
  # =========================================
56
64
 
57
- belongs_to :wallet
58
-
59
- belongs_to :fulfillment, optional: true
65
+ belongs_to :wallet, class_name: "UsageCredits::Wallet", inverse_of: :transactions, optional: false
66
+ belongs_to :transfer, class_name: "UsageCredits::Transfer", optional: true
67
+ belongs_to :fulfillment, class_name: "UsageCredits::Fulfillment", optional: true
60
68
 
69
+ # Re-declare allocation associations with correct classes
61
70
  has_many :outgoing_allocations,
62
- class_name: "UsageCredits::Allocation",
63
- foreign_key: :transaction_id,
64
- dependent: :destroy
71
+ class_name: "UsageCredits::Allocation",
72
+ foreign_key: :transaction_id,
73
+ dependent: :destroy
65
74
 
66
75
  has_many :incoming_allocations,
67
- class_name: "UsageCredits::Allocation",
68
- foreign_key: :source_transaction_id,
69
- dependent: :destroy
70
-
71
- validates :amount, presence: true, numericality: { only_integer: true }
72
- validates :category, presence: true, inclusion: { in: ->(record) { Transaction.categories } }
73
-
74
- validate :remaining_amount_cannot_be_negative
76
+ class_name: "UsageCredits::Allocation",
77
+ foreign_key: :source_transaction_id,
78
+ dependent: :destroy
75
79
 
76
80
  # =========================================
77
- # Scopes
81
+ # Backwards Compatibility Scopes
78
82
  # =========================================
79
83
 
80
- scope :credits_added, -> { where("amount > 0") }
81
- scope :credits_deducted, -> { where("amount < 0") }
82
- scope :by_category, ->(category) { where(category: category) }
83
- scope :recent, -> { order(created_at: :desc) }
84
+ scope :credits_added, -> { where(arel_table[:amount].gt(0)) }
85
+ scope :credits_deducted, -> { where(arel_table[:amount].lt(0)) }
84
86
  scope :operation_charges, -> { where(category: :operation_charge) }
85
87
 
86
- # A transaction is not expired if:
87
- # 1. It has no expiration date, OR
88
- # 2. Its expiration date is in the future
89
- scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
90
- scope :expired, -> { where("expires_at < ?", Time.current) }
91
-
92
-
93
- # =========================================
94
- # Helpers
95
- # =========================================
96
-
97
- # Get the owner of the wallet these credits belong to
98
- def owner
99
- wallet.owner
100
- end
101
-
102
- # Have these credits expired?
103
- def expired?
104
- expires_at.present? && expires_at < Time.current
105
- end
106
-
107
- # Is this transaction a positive credit or a negative (spend)?
108
- def credit?
109
- amount > 0
110
- end
111
-
112
- def debit?
113
- amount < 0
114
- end
115
-
116
- # How many credits from this transaction have already been allocated (spent)?
117
- # Only applies if this transaction is positive.
118
- def allocated_amount
119
- incoming_allocations.sum(:amount)
120
- end
121
-
122
- # How many credits remain unused in this positive transaction?
123
- # If negative, this will effectively be 0.
124
- def remaining_amount
125
- return 0 unless credit?
126
- amount - allocated_amount
127
- end
128
-
129
- # =========================================
130
- # Balance After Transaction
131
- # =========================================
132
-
133
- # Get the balance after this transaction was applied
134
- # Returns nil for transactions created before this feature was added
135
- def balance_after
136
- metadata[:balance_after]
137
- end
138
-
139
- # Get the balance before this transaction was applied
140
- # Returns the stored value if available, otherwise nil
141
- # Note: For transactions created before this feature, returns nil
142
- def balance_before
143
- metadata[:balance_before]
144
- end
145
-
146
88
  # =========================================
147
89
  # Display Formatting
148
90
  # =========================================
@@ -154,7 +96,6 @@ module UsageCredits
154
96
  end
155
97
 
156
98
  # Format the balance after for display (e.g., "500 credits")
157
- # Returns nil if balance_after is not stored
158
99
  def formatted_balance_after
159
100
  return nil unless balance_after
160
101
  UsageCredits.configuration.credit_formatter.call(balance_after)
@@ -162,54 +103,13 @@ module UsageCredits
162
103
 
163
104
  # Get a human-readable description of what this transaction represents
164
105
  def description
165
- # Custom description takes precedence
166
106
  return self[:description] if self[:description].present?
167
-
168
- # Operation charges have dynamic descriptions
169
107
  return operation_description if category == "operation_charge"
170
-
171
- # Use predefined description or fallback to titleized category
172
108
  category.titleize
173
109
  end
174
110
 
175
- # =========================================
176
- # Metadata Handling
177
- # =========================================
178
-
179
- # Sync in-place modifications to metadata before saving
180
- before_save :sync_metadata_cache
181
-
182
- # Get metadata with indifferent access (string/symbol keys)
183
- # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
184
- def metadata
185
- @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
186
- end
187
-
188
- # Set metadata, ensuring consistent storage format
189
- def metadata=(hash)
190
- @indifferent_metadata = nil # Clear cache
191
- super(hash.is_a?(Hash) ? hash.to_h : {})
192
- end
193
-
194
- # Clear metadata cache on reload to ensure fresh data from database
195
- def reload(*)
196
- @indifferent_metadata = nil
197
- super
198
- end
199
-
200
111
  private
201
112
 
202
- # Sync in-place modifications to the cached metadata back to the attribute
203
- # This ensures changes like `metadata["key"] = "value"` are persisted on save
204
- # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
205
- def sync_metadata_cache
206
- if @indifferent_metadata
207
- write_attribute(:metadata, @indifferent_metadata.to_h)
208
- elsif read_attribute(:metadata).nil?
209
- write_attribute(:metadata, {})
210
- end
211
- end
212
-
213
113
  # Format operation charge descriptions (e.g., "Process Video (-10 credits)")
214
114
  def operation_description
215
115
  operation = metadata["operation"]&.to_s&.titleize
@@ -220,12 +120,5 @@ module UsageCredits
220
120
 
221
121
  "#{operation} (-#{cost} credits)"
222
122
  end
223
-
224
- def remaining_amount_cannot_be_negative
225
- if credit? && remaining_amount < 0
226
- errors.add(:base, "Allocated amount exceeds transaction amount")
227
- end
228
- end
229
-
230
123
  end
231
124
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # A transfer records an internal movement of credits between two wallets.
5
+ # The actual balance impact lives in the linked transactions on each side
6
+ # so the transaction history remains explicit.
7
+ #
8
+ # This class extends Wallets::Transfer with usage_credits table configuration.
9
+
10
+ class Transfer < Wallets::TransferBase
11
+ # =========================================
12
+ # Embeddability Configuration
13
+ # =========================================
14
+
15
+ self.embedded_table_name = "usage_credits_transfers"
16
+ self.config_provider = -> { UsageCredits.configuration }
17
+ self.transaction_class_name = "UsageCredits::Transaction"
18
+
19
+ # =========================================
20
+ # Re-declare Associations with Correct Classes
21
+ # =========================================
22
+
23
+ belongs_to :from_wallet,
24
+ class_name: "UsageCredits::Wallet",
25
+ inverse_of: :outgoing_transfers,
26
+ optional: false
27
+ belongs_to :to_wallet,
28
+ class_name: "UsageCredits::Wallet",
29
+ inverse_of: :incoming_transfers,
30
+ optional: false
31
+ has_many :transactions,
32
+ class_name: "UsageCredits::Transaction",
33
+ foreign_key: :transfer_id,
34
+ inverse_of: :transfer,
35
+ dependent: :nullify
36
+ end
37
+ end