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