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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # View helpers for displaying credit information
5
+ module CreditsHelper
6
+ # Format credit amount for display
7
+ def format_credits(amount)
8
+ UsageCredits.configuration.credit_formatter.call(amount)
9
+ end
10
+
11
+ # Format price in currency
12
+ def format_credit_price(cents, currency = nil)
13
+ currency ||= UsageCredits.configuration.default_currency
14
+ format("%.2f %s", cents / 100.0, currency.to_s.upcase)
15
+ end
16
+
17
+ # Credit pack purchase button
18
+ def credit_pack_button(pack, options = {})
19
+ button_to options[:path] || credit_pack_purchase_path(pack),
20
+ class: options[:class] || "credit-pack-button",
21
+ method: :post,
22
+ data: {
23
+ turbo: false,
24
+ pack_name: pack.name,
25
+ credits: pack.credits,
26
+ bonus_credits: pack.bonus_credits,
27
+ price: pack.price_cents
28
+ } do
29
+ render_credit_pack_button_content(pack)
30
+ end
31
+ end
32
+
33
+
34
+ private
35
+
36
+ def render_credit_pack_button_content(pack)
37
+ safe_join([
38
+ content_tag(:span, "#{format_credits(pack.credits)} Credits", class: "credit-amount"),
39
+ pack.bonus_credits.positive? ? content_tag(:span, "+ #{format_credits(pack.bonus_credits)} Bonus", class: "bonus-amount") : nil,
40
+ content_tag(:span, format_credit_price(pack.price_cents, pack.price_currency), class: "price")
41
+ ].compact, " ")
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Handles parsing and normalization of time periods throughout the gem.
5
+ # Converts strings like "1.month" or symbols like :monthly into ActiveSupport::Duration objects.
6
+ module PeriodParser
7
+
8
+ # Canonical periods and their aliases
9
+ VALID_PERIODS = {
10
+ day: [:day, :daily], # 1.day
11
+ week: [:week, :weekly], # 1.week
12
+ month: [:month, :monthly], # 1.month
13
+ quarter: [:quarter, :quarterly], # 3.months
14
+ year: [:year, :yearly, :annually] # 1.year
15
+ }.freeze
16
+
17
+ MIN_PERIOD = 1.day
18
+
19
+ module_function
20
+
21
+ # Turns things like `:monthly` into `1.month` to always store consistent time periods
22
+ def normalize_period(period)
23
+ return nil unless period
24
+
25
+ # Handle ActiveSupport::Duration objects directly
26
+ if period.is_a?(ActiveSupport::Duration)
27
+ raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if period < MIN_PERIOD
28
+ period
29
+ else
30
+ # Convert symbols to canonical durations
31
+ case period
32
+ when *VALID_PERIODS[:day] then 1.day
33
+ when *VALID_PERIODS[:week] then 1.week
34
+ when *VALID_PERIODS[:month] then 1.month
35
+ when *VALID_PERIODS[:quarter] then 3.months
36
+ when *VALID_PERIODS[:year] then 1.year
37
+ else
38
+ raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}"
39
+ end
40
+ end
41
+ end
42
+
43
+ # Parse a period string into an ActiveSupport::Duration
44
+ # @param period_str [String, ActiveSupport::Duration] A string like "1.month" or "1 month" or an existing duration
45
+ # @return [ActiveSupport::Duration] The parsed duration
46
+ # @raise [ArgumentError] If the period string is invalid
47
+ def parse_period(period_str)
48
+ return period_str if period_str.is_a?(ActiveSupport::Duration)
49
+
50
+ if period_str.to_s =~ /\A(\d+)[.\s](\w+)\z/
51
+ amount = $1.to_i
52
+ unit = $2.singularize.to_sym
53
+
54
+ # Validate the unit is supported
55
+ valid_units = VALID_PERIODS.values.flatten
56
+ unless valid_units.include?(unit)
57
+ raise ArgumentError, "Unsupported period unit: #{unit}. Supported units: #{valid_units.inspect}"
58
+ end
59
+
60
+ duration = amount.send(unit)
61
+ raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if duration < MIN_PERIOD
62
+ duration
63
+ else
64
+ raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc."
65
+ end
66
+ end
67
+
68
+ # Validates that a period string matches the expected format and units
69
+ def valid_period_format?(period_str)
70
+ parse_period(period_str)
71
+ true
72
+ rescue ArgumentError
73
+ false
74
+ end
75
+
76
+ end
77
+ end
@@ -0,0 +1,25 @@
1
+ # lib/usage_credits/jobs/fulfillment_job.rb
2
+ module UsageCredits
3
+ class FulfillmentJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform
7
+ Rails.logger.info "Starting credit fulfillment processing"
8
+ start_time = Time.current
9
+
10
+ count = FulfillmentService.process_pending_fulfillments
11
+
12
+ elapsed = Time.current - start_time
13
+ formatted_time = if elapsed >= 60
14
+ "#{(elapsed / 60).floor}m #{(elapsed % 60).round}s"
15
+ else
16
+ "#{elapsed.round(2)}s"
17
+ end
18
+
19
+ Rails.logger.info "Completed processing #{count} fulfillments in #{formatted_time}"
20
+ rescue StandardError => e
21
+ Rails.logger.error "Error processing credit fulfillments: #{e.message}"
22
+ raise # Re-raise to trigger job retry
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # An Allocation links a *negative* (spend) transaction
5
+ # to a *positive* (credit) transaction, indicating how many
6
+ # credits were taken from that specific credit source.
7
+ #
8
+ # Allocations are the basis for the bucket-based, FIFO-with-expiration inventory-like system
9
+ # This is critical for calculating balances when there are mixed expiring and non-expiring credits
10
+ # Otherwise, balance calculations will always be wrong because negative transactions get dragged forever
11
+ # More info: https://x.com/rameerez/status/1884246492837302759
12
+ class Allocation < ApplicationRecord
13
+ self.table_name = "usage_credits_allocations"
14
+
15
+ belongs_to :spend_transaction, class_name: "UsageCredits::Transaction", foreign_key: "transaction_id"
16
+ belongs_to :source_transaction, class_name: "UsageCredits::Transaction"
17
+
18
+ validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
19
+
20
+ validate :allocation_does_not_exceed_remaining_amount
21
+
22
+ private
23
+
24
+ def allocation_does_not_exceed_remaining_amount
25
+ if source_transaction.remaining_amount < amount
26
+ errors.add(:amount, "exceeds the remaining amount of the source transaction")
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Adds credit wallet functionality to a model
5
+ module HasWallet
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_one :credit_wallet,
10
+ class_name: "UsageCredits::Wallet",
11
+ as: :owner,
12
+ dependent: :destroy
13
+
14
+ alias_method :credits_wallet, :credit_wallet
15
+ alias_method :wallet, :credit_wallet
16
+
17
+ after_create :create_credit_wallet, if: :should_create_wallet?
18
+
19
+ # More intuitive delegations
20
+ delegate :credits,
21
+ :credit_history,
22
+ :has_enough_credits_to?,
23
+ :estimate_credits_to,
24
+ :spend_credits_on,
25
+ :give_credits,
26
+ to: :ensure_credit_wallet,
27
+ allow_nil: false # Never return nil for these methods
28
+
29
+ # Fix recursion by properly aliasing the original method
30
+ alias_method :original_credit_wallet, :credit_wallet
31
+
32
+ # Then override it
33
+ define_method(:credit_wallet) do
34
+ ensure_credit_wallet
35
+ end
36
+
37
+ # Returns all active subscriptions as CreditSubscriptionPlan objects
38
+ def subscriptions
39
+ return [] unless credit_wallet
40
+
41
+ credit_wallet.fulfillments
42
+ .where(fulfillment_type: "subscription")
43
+ .active
44
+ .map { |f| UsageCredits.find_subscription_plan_by_processor_id(f.metadata["plan"]) }
45
+ .compact
46
+ end
47
+
48
+ end
49
+
50
+ # Class methods added to the model
51
+ class_methods do
52
+ def has_credits(**options)
53
+ include UsageCredits::HasWallet unless included_modules.include?(UsageCredits::HasWallet)
54
+
55
+ # Initialize class instance variable instead of class variable
56
+ @credit_options = options
57
+
58
+ # Ensure wallet is created by default unless explicitly disabled
59
+ @credit_options[:auto_create] = true if @credit_options[:auto_create].nil?
60
+ end
61
+
62
+ def credit_options
63
+ @credit_options ||= { auto_create: true }
64
+ end
65
+ end
66
+
67
+ def credit_options
68
+ self.class.credit_options
69
+ end
70
+
71
+ private
72
+
73
+ def should_create_wallet?
74
+ credit_options[:auto_create] != false
75
+ end
76
+
77
+ def ensure_credit_wallet
78
+ return original_credit_wallet if original_credit_wallet.present?
79
+ return unless should_create_wallet?
80
+
81
+ if persisted?
82
+ build_credit_wallet(
83
+ balance: credit_options[:initial_balance] || 0
84
+ ).tap(&:save!)
85
+ else
86
+ raise "Cannot create wallet for unsaved owner"
87
+ end
88
+ end
89
+
90
+ def create_credit_wallet
91
+ ensure_credit_wallet
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Extends Pay::Charge with credit pack functionality
5
+ module PayChargeExtension
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_initialize :init_metadata
10
+ after_commit :fulfill_credit_pack!
11
+ after_commit :handle_refund!, on: :update, if: :refund_needed?
12
+ end
13
+
14
+ def init_metadata
15
+ self.metadata ||= {}
16
+ self.data ||= {}
17
+ end
18
+
19
+ def succeeded?
20
+ return true if data["status"] == "succeeded" || data[:status] == "succeeded"
21
+ # For Stripe charges, a successful charge has amount_captured equal to the charge amount
22
+ return true if type == "Pay::Stripe::Charge" && data["amount_captured"] == amount
23
+ false
24
+ end
25
+
26
+ def refunded?
27
+ return false unless amount_refunded
28
+ amount_refunded > 0
29
+ end
30
+
31
+ private
32
+
33
+ # Returns true if the charge has a valid credit wallet to operate on
34
+ def has_valid_wallet?
35
+ return false unless customer&.owner&.respond_to?(:credit_wallet)
36
+ return false unless customer.owner.credit_wallet.present?
37
+ true
38
+ end
39
+
40
+ def credit_wallet
41
+ return nil unless has_valid_wallet?
42
+ customer.owner.credit_wallet
43
+ end
44
+
45
+ def refund_needed?
46
+ saved_change_to_amount_refunded? && amount_refunded.to_i.positive?
47
+ end
48
+
49
+ def is_credit_pack_purchase?
50
+ metadata["purchase_type"] == "credit_pack"
51
+ end
52
+
53
+ def pack_identifier
54
+ metadata["pack_name"]
55
+ end
56
+
57
+ def credits_already_fulfilled?
58
+ # First check if there's a fulfillment record for this charge
59
+ return true if UsageCredits::Fulfillment.exists?(source: self)
60
+
61
+ # Fallback: check transactions directly
62
+ credit_wallet&.transactions&.where(category: "credit_pack_purchase")
63
+ .exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
64
+ end
65
+
66
+ def fulfill_credit_pack!
67
+ return unless is_credit_pack_purchase?
68
+ return unless pack_identifier
69
+ return unless has_valid_wallet?
70
+ return unless succeeded?
71
+ return if refunded?
72
+ return if credits_already_fulfilled?
73
+
74
+ Rails.logger.info "Starting to process charge #{id} to fulfill credits"
75
+
76
+ pack_name = pack_identifier.to_sym
77
+ pack = UsageCredits.find_pack(pack_name)
78
+
79
+ unless pack
80
+ Rails.logger.error "Credit pack not found: #{pack_name} for charge #{id}"
81
+ return
82
+ end
83
+
84
+ # Validate that the pack details match if they're provided in metadata
85
+ if metadata["credits"].present?
86
+ expected_credits = metadata["credits"].to_i
87
+ if expected_credits != pack.credits
88
+ Rails.logger.error "Credit pack mismatch: expected #{expected_credits} credits but pack #{pack_name} provides #{pack.credits}"
89
+ return
90
+ end
91
+ end
92
+
93
+ begin
94
+ # Wrap credit addition in a transaction for atomicity
95
+ ActiveRecord::Base.transaction do
96
+ # Add credits to the user's wallet
97
+ credit_wallet.add_credits(
98
+ pack.total_credits,
99
+ category: "credit_pack_purchase",
100
+ metadata: {
101
+ purchase_charge_id: id,
102
+ purchased_at: created_at,
103
+ credits_fulfilled: true,
104
+ fulfilled_at: Time.current,
105
+ **pack.base_metadata
106
+ }
107
+ )
108
+
109
+ # Also create a one-time fulfillment record for audit and consistency
110
+ # This Fulfillment record won't get picked up by the fulfillment job because `next_fulfillment_at` is nil
111
+ Fulfillment.create!(
112
+ wallet: credit_wallet,
113
+ source: self, # the Pay::Charge
114
+ fulfillment_type: "credit_pack",
115
+ credits_last_fulfillment: pack.total_credits,
116
+ last_fulfilled_at: Time.current,
117
+ next_fulfillment_at: nil, # so it doesn't get re-processed
118
+ metadata: {
119
+ purchase_charge_id: id,
120
+ purchased_at: created_at,
121
+ **pack.base_metadata
122
+ }
123
+ )
124
+ end
125
+
126
+ Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}"
127
+ rescue StandardError => e
128
+ Rails.logger.error "Failed to fulfill credit pack #{pack_name} for charge #{id}: #{e.message}"
129
+ raise
130
+ end
131
+ end
132
+
133
+ def credits_already_refunded?
134
+ # Check if refund was already processed with credits deducted by looking for a refund transaction
135
+ credit_wallet&.transactions&.where(category: "credit_pack_refund")
136
+ .exists?(['metadata @> ?', { refunded_purchase_charge_id: id, credits_refunded: true }.to_json])
137
+ end
138
+
139
+ def handle_refund!
140
+ # Guard clauses for required data and state
141
+ return unless refunded?
142
+ return unless pack_identifier
143
+ return unless has_valid_wallet?
144
+ return unless amount.is_a?(Numeric) && amount.positive?
145
+ return if credits_already_refunded?
146
+
147
+ pack_name = pack_identifier.to_sym
148
+ pack = UsageCredits.find_pack(pack_name)
149
+
150
+ unless pack
151
+ Rails.logger.error "Credit pack not found for refund: #{pack_name} for charge #{id}"
152
+ return
153
+ end
154
+
155
+ # Validate refund amount
156
+ if amount_refunded > amount
157
+ Rails.logger.error "Invalid refund amount: #{amount_refunded} exceeds original charge amount #{amount} for charge #{id}"
158
+ return
159
+ end
160
+
161
+ # Calculate refund ratio and credits to remove
162
+ # Always use ceil for credit calculations to avoid giving more credits than paid for
163
+ refund_ratio = amount_refunded.to_f / amount.to_f
164
+ credits_to_remove = (pack.total_credits * refund_ratio).ceil
165
+
166
+ begin
167
+ Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (#{(refund_ratio * 100).round(2)}% of #{pack.total_credits})"
168
+
169
+ # Wrap credit deduction in a transaction for atomicity
170
+ ActiveRecord::Base.transaction do
171
+ credit_wallet.deduct_credits(
172
+ credits_to_remove,
173
+ category: "credit_pack_refund",
174
+ metadata: {
175
+ refunded_purchase_charge_id: id,
176
+ credits_refunded: true,
177
+ refunded_at: Time.current,
178
+ refund_percentage: refund_ratio,
179
+ refund_amount_cents: amount_refunded,
180
+ **pack.base_metadata
181
+ }
182
+ )
183
+ end
184
+
185
+ Rails.logger.info "Successfully processed refund for charge #{id}"
186
+ rescue UsageCredits::InsufficientCredits => e
187
+ Rails.logger.error "Insufficient credits for refund on charge #{id}: #{e.message}"
188
+ # If negative balance not allowed and user has used credits,
189
+ # we'll let the error propagate
190
+ raise
191
+ rescue StandardError => e
192
+ Rails.logger.error "Failed to process refund for charge #{id}: #{e.message}"
193
+ raise
194
+ end
195
+ end
196
+
197
+ end
198
+ end