usage_credits 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +32 -0
- data/lib/generators/usage_credits/install_generator.rb +49 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
- data/lib/generators/usage_credits/templates/initializer.rb +105 -0
- data/lib/usage_credits/configuration.rb +204 -0
- data/lib/usage_credits/core_ext/numeric.rb +59 -0
- data/lib/usage_credits/cost/base.rb +43 -0
- data/lib/usage_credits/cost/compound.rb +37 -0
- data/lib/usage_credits/cost/fixed.rb +34 -0
- data/lib/usage_credits/cost/variable.rb +42 -0
- data/lib/usage_credits/engine.rb +37 -0
- data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
- data/lib/usage_credits/helpers/credits_helper.rb +45 -0
- data/lib/usage_credits/helpers/period_parser.rb +77 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
- data/lib/usage_credits/models/allocation.rb +31 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
- data/lib/usage_credits/models/credit_pack.rb +159 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
- data/lib/usage_credits/models/fulfillment.rb +91 -0
- data/lib/usage_credits/models/operation.rb +153 -0
- data/lib/usage_credits/models/transaction.rb +174 -0
- data/lib/usage_credits/models/wallet.rb +310 -0
- data/lib/usage_credits/railtie.rb +17 -0
- data/lib/usage_credits/services/fulfillment_service.rb +129 -0
- data/lib/usage_credits/version.rb +5 -0
- data/lib/usage_credits.rb +170 -0
- data/sig/usagecredits.rbs +4 -0
- 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
|