usage_credits 0.4.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 +72 -0
  3. data/README.md +246 -13
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
  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 +43 -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 +55 -152
  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 -48
  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,136 +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
- # All possible transaction types, grouped by purpose:
19
- CATEGORIES = [
23
+ # Override base categories with usage_credits-specific ones
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
 
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
+
57
+ def self.categories
58
+ (DEFAULT_CATEGORIES + resolved_config.additional_categories).uniq
59
+ end
60
+
43
61
  # =========================================
44
- # Associations & Validations
62
+ # Additional Associations
45
63
  # =========================================
46
64
 
47
- belongs_to :wallet
48
-
49
- 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
50
68
 
69
+ # Re-declare allocation associations with correct classes
51
70
  has_many :outgoing_allocations,
52
- class_name: "UsageCredits::Allocation",
53
- foreign_key: :transaction_id,
54
- dependent: :destroy
71
+ class_name: "UsageCredits::Allocation",
72
+ foreign_key: :transaction_id,
73
+ dependent: :destroy
55
74
 
56
75
  has_many :incoming_allocations,
57
- class_name: "UsageCredits::Allocation",
58
- foreign_key: :source_transaction_id,
59
- dependent: :destroy
60
-
61
- validates :amount, presence: true, numericality: { only_integer: true }
62
- validates :category, presence: true, inclusion: { in: CATEGORIES }
63
-
64
- validate :remaining_amount_cannot_be_negative
76
+ class_name: "UsageCredits::Allocation",
77
+ foreign_key: :source_transaction_id,
78
+ dependent: :destroy
65
79
 
66
80
  # =========================================
67
- # Scopes
81
+ # Backwards Compatibility Scopes
68
82
  # =========================================
69
83
 
70
- scope :credits_added, -> { where("amount > 0") }
71
- scope :credits_deducted, -> { where("amount < 0") }
72
- scope :by_category, ->(category) { where(category: category) }
73
- 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)) }
74
86
  scope :operation_charges, -> { where(category: :operation_charge) }
75
87
 
76
- # A transaction is not expired if:
77
- # 1. It has no expiration date, OR
78
- # 2. Its expiration date is in the future
79
- scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
80
- scope :expired, -> { where("expires_at < ?", Time.current) }
81
-
82
-
83
- # =========================================
84
- # Helpers
85
- # =========================================
86
-
87
- # Get the owner of the wallet these credits belong to
88
- def owner
89
- wallet.owner
90
- end
91
-
92
- # Have these credits expired?
93
- def expired?
94
- expires_at.present? && expires_at < Time.current
95
- end
96
-
97
- # Is this transaction a positive credit or a negative (spend)?
98
- def credit?
99
- amount > 0
100
- end
101
-
102
- def debit?
103
- amount < 0
104
- end
105
-
106
- # How many credits from this transaction have already been allocated (spent)?
107
- # Only applies if this transaction is positive.
108
- def allocated_amount
109
- incoming_allocations.sum(:amount)
110
- end
111
-
112
- # How many credits remain unused in this positive transaction?
113
- # If negative, this will effectively be 0.
114
- def remaining_amount
115
- return 0 unless credit?
116
- amount - allocated_amount
117
- end
118
-
119
- # =========================================
120
- # Balance After Transaction
121
- # =========================================
122
-
123
- # Get the balance after this transaction was applied
124
- # Returns nil for transactions created before this feature was added
125
- def balance_after
126
- metadata[:balance_after]
127
- end
128
-
129
- # Get the balance before this transaction was applied
130
- # Returns the stored value if available, otherwise nil
131
- # Note: For transactions created before this feature, returns nil
132
- def balance_before
133
- metadata[:balance_before]
134
- end
135
-
136
88
  # =========================================
137
89
  # Display Formatting
138
90
  # =========================================
@@ -144,7 +96,6 @@ module UsageCredits
144
96
  end
145
97
 
146
98
  # Format the balance after for display (e.g., "500 credits")
147
- # Returns nil if balance_after is not stored
148
99
  def formatted_balance_after
149
100
  return nil unless balance_after
150
101
  UsageCredits.configuration.credit_formatter.call(balance_after)
@@ -152,54 +103,13 @@ module UsageCredits
152
103
 
153
104
  # Get a human-readable description of what this transaction represents
154
105
  def description
155
- # Custom description takes precedence
156
106
  return self[:description] if self[:description].present?
157
-
158
- # Operation charges have dynamic descriptions
159
107
  return operation_description if category == "operation_charge"
160
-
161
- # Use predefined description or fallback to titleized category
162
108
  category.titleize
163
109
  end
164
110
 
165
- # =========================================
166
- # Metadata Handling
167
- # =========================================
168
-
169
- # Sync in-place modifications to metadata before saving
170
- before_save :sync_metadata_cache
171
-
172
- # Get metadata with indifferent access (string/symbol keys)
173
- # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
174
- def metadata
175
- @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
176
- end
177
-
178
- # Set metadata, ensuring consistent storage format
179
- def metadata=(hash)
180
- @indifferent_metadata = nil # Clear cache
181
- super(hash.is_a?(Hash) ? hash.to_h : {})
182
- end
183
-
184
- # Clear metadata cache on reload to ensure fresh data from database
185
- def reload(*)
186
- @indifferent_metadata = nil
187
- super
188
- end
189
-
190
111
  private
191
112
 
192
- # Sync in-place modifications to the cached metadata back to the attribute
193
- # This ensures changes like `metadata["key"] = "value"` are persisted on save
194
- # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
195
- def sync_metadata_cache
196
- if @indifferent_metadata
197
- write_attribute(:metadata, @indifferent_metadata.to_h)
198
- elsif read_attribute(:metadata).nil?
199
- write_attribute(:metadata, {})
200
- end
201
- end
202
-
203
113
  # Format operation charge descriptions (e.g., "Process Video (-10 credits)")
204
114
  def operation_description
205
115
  operation = metadata["operation"]&.to_s&.titleize
@@ -210,12 +120,5 @@ module UsageCredits
210
120
 
211
121
  "#{operation} (-#{cost} credits)"
212
122
  end
213
-
214
- def remaining_amount_cannot_be_negative
215
- if credit? && remaining_amount < 0
216
- errors.add(:base, "Allocated amount exceeds transaction amount")
217
- end
218
- end
219
-
220
123
  end
221
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