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.
- 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,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
4
|
+
def change
|
5
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
6
|
+
|
7
|
+
create_table :usage_credits_wallets, id: primary_key_type do |t|
|
8
|
+
t.references :owner, polymorphic: true, null: false, type: foreign_key_type
|
9
|
+
t.integer :balance, null: false, default: 0
|
10
|
+
t.send(json_column_type, :metadata, null: false, default: {})
|
11
|
+
|
12
|
+
t.timestamps
|
13
|
+
end
|
14
|
+
|
15
|
+
create_table :usage_credits_transactions, id: primary_key_type do |t|
|
16
|
+
t.references :wallet, null: false, type: foreign_key_type
|
17
|
+
t.integer :amount, null: false
|
18
|
+
t.string :category, null: false
|
19
|
+
t.datetime :expires_at
|
20
|
+
t.references :fulfillment, type: foreign_key_type
|
21
|
+
t.send(json_column_type, :metadata, null: false, default: {})
|
22
|
+
|
23
|
+
t.timestamps
|
24
|
+
end
|
25
|
+
|
26
|
+
create_table :usage_credits_fulfillments, id: primary_key_type do |t|
|
27
|
+
t.references :wallet, null: false, type: foreign_key_type
|
28
|
+
t.references :source, polymorphic: true, type: foreign_key_type
|
29
|
+
t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment
|
30
|
+
t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription)
|
31
|
+
t.datetime :last_fulfilled_at # When last fulfilled
|
32
|
+
t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
|
33
|
+
t.string :fulfillment_period # "2.months", "15.days", etc. (nil for one-time)
|
34
|
+
t.datetime :stops_at # When to stop performing fulfillments
|
35
|
+
t.send(json_column_type, :metadata, null: false, default: {})
|
36
|
+
|
37
|
+
t.timestamps
|
38
|
+
end
|
39
|
+
|
40
|
+
# Allocations are the basis for the bucket-based, FIFO with expiration inventory-like system
|
41
|
+
create_table :usage_credits_allocations, id: primary_key_type do |t|
|
42
|
+
# The "spend" transaction (negative) that is *using* credits
|
43
|
+
t.references :transaction, null: false, type: foreign_key_type,
|
44
|
+
foreign_key: { to_table: :usage_credits_transactions },
|
45
|
+
index: { name: "index_allocations_on_transaction_id" }
|
46
|
+
|
47
|
+
# The "source" transaction (positive) from which the credits are drawn
|
48
|
+
t.references :source_transaction, null: false, type: foreign_key_type,
|
49
|
+
foreign_key: { to_table: :usage_credits_transactions },
|
50
|
+
index: { name: "index_allocations_on_source_transaction_id" }
|
51
|
+
|
52
|
+
# How many credits were allocated from that particular source
|
53
|
+
t.integer :amount, null: false
|
54
|
+
|
55
|
+
t.timestamps
|
56
|
+
end
|
57
|
+
|
58
|
+
# Add indexes
|
59
|
+
add_index :usage_credits_transactions, :category
|
60
|
+
add_index :usage_credits_transactions, :expires_at
|
61
|
+
|
62
|
+
# Composite index on (expires_at, id) for efficient ordering when calculating balances
|
63
|
+
add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id'
|
64
|
+
|
65
|
+
# Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts
|
66
|
+
add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount'
|
67
|
+
|
68
|
+
add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx"
|
69
|
+
|
70
|
+
add_index :usage_credits_fulfillments, :next_fulfillment_at
|
71
|
+
add_index :usage_credits_fulfillments, :fulfillment_type
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def primary_and_foreign_key_types
|
77
|
+
config = Rails.configuration.generators
|
78
|
+
setting = config.options[config.orm][:primary_key_type]
|
79
|
+
primary_key_type = setting || :primary_key
|
80
|
+
foreign_key_type = setting || :bigint
|
81
|
+
[primary_key_type, foreign_key_type]
|
82
|
+
end
|
83
|
+
|
84
|
+
def json_column_type
|
85
|
+
return :jsonb if connection.adapter_name.downcase.include?('postgresql')
|
86
|
+
:json
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
UsageCredits.configure do |config|
|
4
|
+
#
|
5
|
+
# Define your credit-consuming operations below
|
6
|
+
#
|
7
|
+
# Example:
|
8
|
+
#
|
9
|
+
# operation :send_email do
|
10
|
+
# costs 1.credit
|
11
|
+
# end
|
12
|
+
#
|
13
|
+
# operation :process_image do
|
14
|
+
# costs 10.credits + 1.credit_per(:mb)
|
15
|
+
# validate ->(params) { params[:size] <= 100.megabytes }, "File too large"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# operation :generate_ai_response do
|
19
|
+
# costs 5.credits
|
20
|
+
# validate ->(params) { params[:prompt].length <= 1000 }, "Prompt too long"
|
21
|
+
# meta category: :ai, description: "Generate AI response"
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# operation :process_items do
|
25
|
+
# costs 1.credit_per(:units) # Cost per item processed
|
26
|
+
# meta category: :batch, description: "Process items in batch"
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
#
|
30
|
+
#
|
31
|
+
# Example credit packs (uncomment and modify as needed):
|
32
|
+
#
|
33
|
+
# credit_pack :tiny do
|
34
|
+
# gives 100.credits
|
35
|
+
# costs 99.cents # Price can be in cents or dollars
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# credit_pack :starter do
|
39
|
+
# gives 1000.credits
|
40
|
+
# bonus 100.credits # Optional bonus credits
|
41
|
+
# costs 49.dollars
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# credit_pack :pro do
|
45
|
+
# gives 5000.credits
|
46
|
+
# bonus 1000.credits
|
47
|
+
# costs 199.dollars
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
#
|
51
|
+
#
|
52
|
+
# Example subscription plans (uncomment and modify as needed):
|
53
|
+
#
|
54
|
+
# subscription_plan :basic do
|
55
|
+
# gives 1000.credits.every(:month)
|
56
|
+
# signup_bonus 100.credits
|
57
|
+
# unused_credits :expire # Credits reset each month
|
58
|
+
# end
|
59
|
+
#
|
60
|
+
# subscription_plan :pro do
|
61
|
+
# gives 10_000.credits.every(:month)
|
62
|
+
# signup_bonus 1_000.credits
|
63
|
+
# trial_includes 500.credits
|
64
|
+
# unused_credits :expire # Credits expire at the end of the fulfillment period (use :rollover to roll over to next period)
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
#
|
68
|
+
#
|
69
|
+
# Alert when balance drops below this threshold (default: 100 credits)
|
70
|
+
# Set to nil to disable low balance alerts
|
71
|
+
#
|
72
|
+
# config.low_balance_threshold = 100.credits
|
73
|
+
#
|
74
|
+
#
|
75
|
+
# Handle low credit balance alerts – Useful to sell booster credit packs, for example
|
76
|
+
#
|
77
|
+
# config.on_low_balance do |user|
|
78
|
+
# Send notification to user when their balance drops below the threshold
|
79
|
+
# UserMailer.low_credits_alert(user).deliver_later
|
80
|
+
# end
|
81
|
+
#
|
82
|
+
#
|
83
|
+
#
|
84
|
+
# For how long expiring credits from the previous fulfillment cycle will "overlap" the following fulfillment period.
|
85
|
+
# During this time, old credits from the previous period will erroneously count as available balance.
|
86
|
+
# But if we set this to 0 or nil, user balance will show up as zero some time until the next fulfillment cycle hits.
|
87
|
+
# A good default is to match the frequency of your UsageCredits::FulfillmentJob
|
88
|
+
# fulfillment_grace_period = 5.minutes
|
89
|
+
#
|
90
|
+
#
|
91
|
+
#
|
92
|
+
# Rounding strategy for credit calculations (default: :ceil)
|
93
|
+
# :ceil - Always round up (2.1 => 3)
|
94
|
+
# :floor - Always round down (2.9 => 2)
|
95
|
+
# :round - Standard rounding (2.4 => 2, 2.6 => 3)
|
96
|
+
#
|
97
|
+
# config.rounding_strategy = :ceil
|
98
|
+
#
|
99
|
+
#
|
100
|
+
# Format credits for display (default: "X credits")
|
101
|
+
#
|
102
|
+
# config.format_credits do |amount|
|
103
|
+
# "#{amount} credits"
|
104
|
+
# end
|
105
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Configuration for the UsageCredits gem. This is the single source of truth for all settings.
|
5
|
+
# This is what turns what's defined in the initializer DSL into actual objects we can use and operate with.
|
6
|
+
class Configuration
|
7
|
+
VALID_ROUNDING_STRATEGIES = [:ceil, :floor, :round].freeze
|
8
|
+
VALID_CURRENCIES = [:usd, :eur, :gbp, :sgd, :chf].freeze
|
9
|
+
|
10
|
+
# =========================================
|
11
|
+
# Core Data Stores
|
12
|
+
# =========================================
|
13
|
+
|
14
|
+
# Stores all the things users can do with credits
|
15
|
+
attr_reader :operations
|
16
|
+
|
17
|
+
# Stores all the things users can buy or subscribe to
|
18
|
+
attr_reader :credit_packs
|
19
|
+
attr_reader :credit_subscription_plans
|
20
|
+
|
21
|
+
# =========================================
|
22
|
+
# Basic Settings
|
23
|
+
# =========================================
|
24
|
+
|
25
|
+
attr_reader :default_currency
|
26
|
+
|
27
|
+
attr_reader :rounding_strategy
|
28
|
+
|
29
|
+
attr_reader :credit_formatter
|
30
|
+
|
31
|
+
attr_accessor :fulfillment_grace_period
|
32
|
+
|
33
|
+
# =========================================
|
34
|
+
# Low balance
|
35
|
+
# =========================================
|
36
|
+
|
37
|
+
attr_accessor :allow_negative_balance
|
38
|
+
|
39
|
+
attr_reader :low_balance_threshold
|
40
|
+
|
41
|
+
attr_reader :low_balance_callback
|
42
|
+
|
43
|
+
def initialize
|
44
|
+
# Initialize empty data stores
|
45
|
+
@operations = {} # Credit-consuming operations (e.g., "send_email: 1 credit")
|
46
|
+
@credit_packs = {} # One-time purchases (e.g., "100 credits for $49")
|
47
|
+
@credit_subscription_plans = {} # Recurring plans (e.g., "1000 credits/month for $99")
|
48
|
+
|
49
|
+
# Set sensible defaults
|
50
|
+
@default_currency = :usd
|
51
|
+
@rounding_strategy = :ceil # Always round up to ensure we never undercharge
|
52
|
+
@credit_formatter = ->(amount) { "#{amount} credits" } # How to format credit amounts in the UI
|
53
|
+
|
54
|
+
# Grace period for credit expiration after fulfillment period ends.
|
55
|
+
# For how long will expiring credits "overlap" the following fulfillment period.
|
56
|
+
# This ensures smooth transition between fulfillment periods.
|
57
|
+
# For this amount of time, old, already expired credits will be erroneously counted as available in the user's balance.
|
58
|
+
# Keep it short enough that users don't notice they have the last period's credits still available, but
|
59
|
+
# long enough that there's a smooth transition and users never get zero credits in between fullfillment periods
|
60
|
+
# A good setting is to match the frequency of your UsageCredits::FulfillmentJob runs
|
61
|
+
@fulfillment_grace_period = 5.minutes # If you run your fulfillment job every 5 minutes, this should be enough
|
62
|
+
|
63
|
+
@allow_negative_balance = false
|
64
|
+
@low_balance_threshold = nil
|
65
|
+
@low_balance_callback = nil # Called when user hits low_balance_threshold
|
66
|
+
end
|
67
|
+
|
68
|
+
# =========================================
|
69
|
+
# DSL Methods for Defining Things
|
70
|
+
# =========================================
|
71
|
+
|
72
|
+
# Define a credit-consuming operation
|
73
|
+
def operation(name, &block)
|
74
|
+
raise ArgumentError, "Block is required for operation definition" unless block_given?
|
75
|
+
operation = Operation.new(name)
|
76
|
+
operation.instance_eval(&block)
|
77
|
+
@operations[name.to_sym] = operation
|
78
|
+
operation
|
79
|
+
end
|
80
|
+
|
81
|
+
# Define a one-time purchase credit pack
|
82
|
+
def credit_pack(name, &block)
|
83
|
+
raise ArgumentError, "Block is required for credit pack definition" unless block_given?
|
84
|
+
raise ArgumentError, "Credit pack name can't be blank" if name.blank?
|
85
|
+
|
86
|
+
name = name.to_sym
|
87
|
+
pack = CreditPack.new(name)
|
88
|
+
pack.instance_eval(&block)
|
89
|
+
pack.validate!
|
90
|
+
@credit_packs[name] = pack
|
91
|
+
end
|
92
|
+
|
93
|
+
# Define a recurring subscription plan
|
94
|
+
def subscription_plan(name, &block)
|
95
|
+
raise ArgumentError, "Block is required for subscription plan definition" unless block_given?
|
96
|
+
raise ArgumentError, "Subscription plan name can't be blank" if name.blank?
|
97
|
+
|
98
|
+
name = name.to_sym
|
99
|
+
plan = CreditSubscriptionPlan.new(name)
|
100
|
+
plan.instance_eval(&block)
|
101
|
+
plan.validate!
|
102
|
+
@credit_subscription_plans[name] = plan
|
103
|
+
end
|
104
|
+
|
105
|
+
# Find a subscription plan by its processor-specific ID
|
106
|
+
def find_subscription_plan_by_processor_id(processor_id)
|
107
|
+
@credit_subscription_plans.values.find do |plan|
|
108
|
+
plan.processor_plan_ids.values.include?(processor_id)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# =========================================
|
113
|
+
# Configuration Setters
|
114
|
+
# =========================================
|
115
|
+
|
116
|
+
# Set default currency with validation
|
117
|
+
def default_currency=(value)
|
118
|
+
value = value.to_s.downcase.to_sym
|
119
|
+
unless VALID_CURRENCIES.include?(value)
|
120
|
+
raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}"
|
121
|
+
end
|
122
|
+
@default_currency = value
|
123
|
+
end
|
124
|
+
|
125
|
+
# Set low balance threshold with validation
|
126
|
+
def low_balance_threshold=(value)
|
127
|
+
if value
|
128
|
+
value = value.to_i
|
129
|
+
raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative?
|
130
|
+
end
|
131
|
+
@low_balance_threshold = value
|
132
|
+
end
|
133
|
+
|
134
|
+
# Set rounding strategy with validation
|
135
|
+
def rounding_strategy=(strategy)
|
136
|
+
strategy = strategy.to_sym if strategy.respond_to?(:to_sym)
|
137
|
+
unless VALID_ROUNDING_STRATEGIES.include?(strategy)
|
138
|
+
strategy = :ceil # Default to ceiling if invalid
|
139
|
+
end
|
140
|
+
@rounding_strategy = strategy
|
141
|
+
end
|
142
|
+
|
143
|
+
def fulfillment_grace_period=(value)
|
144
|
+
if value.nil? || value&.to_i == 0
|
145
|
+
@fulfillment_grace_period = 1.second
|
146
|
+
return
|
147
|
+
end
|
148
|
+
|
149
|
+
unless value.is_a?(ActiveSupport::Duration)
|
150
|
+
raise ArgumentError, "Fulfillment grace period must be an ActiveSupport::Duration (e.g. 1.day, 7.minutes)"
|
151
|
+
end
|
152
|
+
|
153
|
+
@fulfillment_grace_period = value
|
154
|
+
end
|
155
|
+
|
156
|
+
# =========================================
|
157
|
+
# Callback & Formatter Configuration
|
158
|
+
# =========================================
|
159
|
+
|
160
|
+
# Set how credits are displayed in the UI
|
161
|
+
def format_credits(&block)
|
162
|
+
@credit_formatter = block
|
163
|
+
end
|
164
|
+
|
165
|
+
# Set what happens when credits are low
|
166
|
+
def on_low_balance(&block)
|
167
|
+
raise ArgumentError, "Block is required for low balance callback" unless block_given?
|
168
|
+
@low_balance_callback = block
|
169
|
+
end
|
170
|
+
|
171
|
+
# =========================================
|
172
|
+
# Validation
|
173
|
+
# =========================================
|
174
|
+
|
175
|
+
# Ensure configuration is valid
|
176
|
+
def validate!
|
177
|
+
validate_currency!
|
178
|
+
validate_threshold!
|
179
|
+
validate_rounding_strategy!
|
180
|
+
true
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
def validate_currency!
|
186
|
+
raise ArgumentError, "Default currency can't be blank" if default_currency.blank?
|
187
|
+
unless VALID_CURRENCIES.include?(default_currency.to_s.downcase.to_sym)
|
188
|
+
raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}"
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def validate_threshold!
|
193
|
+
if @low_balance_threshold && @low_balance_threshold.negative?
|
194
|
+
raise ArgumentError, "Low balance threshold must be greater than or equal to zero"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def validate_rounding_strategy!
|
199
|
+
unless VALID_ROUNDING_STRATEGIES.include?(@rounding_strategy)
|
200
|
+
raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(', ')}"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/numeric"
|
4
|
+
|
5
|
+
# This is what allows us to write things like `3.credits` in the DSL
|
6
|
+
# Then the actual cost gets calculated in the UsageCredits::Cost classes
|
7
|
+
# (Cost::Base, Cost::Fixed, Cost::Variable, Cost::Compound, etc.)
|
8
|
+
class Numeric
|
9
|
+
def credits
|
10
|
+
raise ArgumentError, "Credit amount must be a whole number (decimals are not allowed)" unless self == self.to_i
|
11
|
+
raise ArgumentError, "Credit amount cannot be negative" if self.negative?
|
12
|
+
UsageCredits::Cost::Fixed.new(self.to_i)
|
13
|
+
end
|
14
|
+
alias_method :credit, :credits
|
15
|
+
|
16
|
+
def credits_per(unit)
|
17
|
+
raise ArgumentError, "Credit cost rate must be a whole number (decimals are not allowed)" unless self == self.to_i
|
18
|
+
|
19
|
+
# Convert common units to their base unit
|
20
|
+
unit = case unit.to_s.downcase
|
21
|
+
when "mb", "megabyte", "megabytes"
|
22
|
+
:mb
|
23
|
+
when "kb", "kilobyte", "kilobytes"
|
24
|
+
:kb
|
25
|
+
when "gb", "gigabyte", "gigabytes"
|
26
|
+
:gb
|
27
|
+
when "unit", "units"
|
28
|
+
:units
|
29
|
+
else
|
30
|
+
unit.to_sym
|
31
|
+
end
|
32
|
+
|
33
|
+
UsageCredits::Cost::Variable.new(self, unit)
|
34
|
+
end
|
35
|
+
alias_method :credit_per, :credits_per
|
36
|
+
|
37
|
+
def dollars
|
38
|
+
self * 100 # Convert to cents for payment processors
|
39
|
+
end
|
40
|
+
alias_method :dollar, :dollars
|
41
|
+
alias_method :euro, :dollars
|
42
|
+
alias_method :euros, :dollars
|
43
|
+
alias_method :pound, :dollars
|
44
|
+
alias_method :pounds, :dollars
|
45
|
+
|
46
|
+
def cents
|
47
|
+
self
|
48
|
+
end
|
49
|
+
alias_method :cent, :cents
|
50
|
+
end
|
51
|
+
|
52
|
+
# This is what allows us to write .credit amounts as Procs, like:
|
53
|
+
# cost ->(params) { 2 * params[:variable] }.credits
|
54
|
+
class Proc
|
55
|
+
def credits
|
56
|
+
UsageCredits::Cost::Fixed.new(self)
|
57
|
+
end
|
58
|
+
alias_method :credit, :credits
|
59
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
module Cost
|
5
|
+
# Base class for all credit cost calculations.
|
6
|
+
# Subclasses implement different ways of calculating credit costs:
|
7
|
+
# - Fixed: Simple fixed amount
|
8
|
+
# - Variable: Amount based on units (MB, etc)
|
9
|
+
# - Compound: Multiple costs added together
|
10
|
+
class Base
|
11
|
+
attr_reader :amount
|
12
|
+
|
13
|
+
def initialize(amount)
|
14
|
+
validate_amount!(amount) unless amount.is_a?(Proc)
|
15
|
+
@amount = amount
|
16
|
+
end
|
17
|
+
|
18
|
+
def +(other)
|
19
|
+
case other
|
20
|
+
when Fixed, Variable
|
21
|
+
Compound.new(self, other)
|
22
|
+
else
|
23
|
+
raise ArgumentError, "Cannot add #{other.class} to #{self.class}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_i
|
28
|
+
calculate({})
|
29
|
+
end
|
30
|
+
|
31
|
+
protected
|
32
|
+
|
33
|
+
def validate_amount!(amount)
|
34
|
+
unless amount == amount.to_i
|
35
|
+
raise ArgumentError, "Credit amount must be a whole number (got: #{amount})"
|
36
|
+
end
|
37
|
+
if amount.negative?
|
38
|
+
raise ArgumentError, "Credit amount cannot be negative (got: #{amount})"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
module Cost
|
5
|
+
# Compound credit cost that adds multiple costs together
|
6
|
+
# Example: base cost + per MB cost
|
7
|
+
class Compound < Base
|
8
|
+
attr_reader :costs
|
9
|
+
|
10
|
+
def initialize(*costs)
|
11
|
+
@costs = costs.flatten
|
12
|
+
end
|
13
|
+
|
14
|
+
def calculate(params = {})
|
15
|
+
total = costs.sum { |cost| cost.calculate(params) }
|
16
|
+
CreditCalculator.apply_rounding(total)
|
17
|
+
end
|
18
|
+
|
19
|
+
def +(other)
|
20
|
+
case other
|
21
|
+
when Fixed, Variable
|
22
|
+
self.class.new(costs + [other])
|
23
|
+
else
|
24
|
+
raise ArgumentError, "Cannot add #{other.class} to #{self.class}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.credits(amount)
|
30
|
+
Fixed.new(amount)
|
31
|
+
end
|
32
|
+
|
33
|
+
class_eval do
|
34
|
+
singleton_class.alias_method :credit, :credits
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
module Cost
|
5
|
+
# Fixed credit cost (e.g., always costs 10 credits)
|
6
|
+
class Fixed < Base
|
7
|
+
attr_reader :period
|
8
|
+
|
9
|
+
def initialize(amount)
|
10
|
+
@amount = amount
|
11
|
+
@period = nil # Will default to 1.month in CreditSubscriptionPlan
|
12
|
+
end
|
13
|
+
|
14
|
+
def calculate(params = {})
|
15
|
+
value = amount.is_a?(Proc) ? amount.call(params) : amount
|
16
|
+
case value
|
17
|
+
when UsageCredits::Cost::Fixed
|
18
|
+
value.calculate(params)
|
19
|
+
else
|
20
|
+
validate_amount!(value)
|
21
|
+
value.to_i
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Set the recurring period for subscription plans
|
26
|
+
# @param period [Symbol, ActiveSupport::Duration, nil] The period (e.g., :month, 2.months, 15.days)
|
27
|
+
# @return [self]
|
28
|
+
def every(period = nil)
|
29
|
+
@period = period # nil will default to 1.month in CreditSubscriptionPlan
|
30
|
+
self
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
module Cost
|
5
|
+
# Variable credit cost based on units (e.g., 1 credit per MB)
|
6
|
+
class Variable < Base
|
7
|
+
attr_reader :unit
|
8
|
+
|
9
|
+
def initialize(amount, unit)
|
10
|
+
super(amount)
|
11
|
+
@unit = unit.to_sym
|
12
|
+
end
|
13
|
+
|
14
|
+
def calculate(params = {})
|
15
|
+
size = extract_size(params)
|
16
|
+
raw_cost = amount * size
|
17
|
+
CreditCalculator.apply_rounding(raw_cost)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def extract_size(params)
|
23
|
+
case unit
|
24
|
+
when :mb
|
25
|
+
# First check for direct MB value
|
26
|
+
if params[:mb]
|
27
|
+
CreditCalculator.apply_rounding(params[:mb].to_f)
|
28
|
+
# Then check for bytes that need conversion
|
29
|
+
elsif params[:size]
|
30
|
+
CreditCalculator.apply_rounding(params[:size].to_f / 1.megabyte)
|
31
|
+
else
|
32
|
+
0
|
33
|
+
end
|
34
|
+
when :units
|
35
|
+
CreditCalculator.apply_rounding(params.fetch(:units, 0).to_f)
|
36
|
+
else
|
37
|
+
raise ArgumentError, "Unknown unit: #{unit}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Rails engine for UsageCredits
|
5
|
+
class Engine < ::Rails::Engine
|
6
|
+
isolate_namespace UsageCredits
|
7
|
+
|
8
|
+
# Ensure our models load first
|
9
|
+
config.autoload_paths << File.expand_path("../models", __dir__)
|
10
|
+
config.autoload_paths << File.expand_path("../models/concerns", __dir__)
|
11
|
+
|
12
|
+
# Set up autoloading paths
|
13
|
+
initializer "usage_credits.autoload", before: :set_autoload_paths do |app|
|
14
|
+
app.config.autoload_paths << root.join("lib")
|
15
|
+
app.config.autoload_paths << root.join("lib/usage_credits/models")
|
16
|
+
app.config.autoload_paths << root.join("lib/usage_credits/models/concerns")
|
17
|
+
end
|
18
|
+
|
19
|
+
# Add has_credits method to ActiveRecord::Base
|
20
|
+
initializer "usage_credits.active_record" do
|
21
|
+
ActiveSupport.on_load(:active_record) do
|
22
|
+
extend UsageCredits::HasWallet::ClassMethods
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
initializer "usage_credits.pay_integration" do
|
27
|
+
ActiveSupport.on_load(:pay) do
|
28
|
+
Pay::Subscription.include UsageCredits::PaySubscriptionExtension
|
29
|
+
Pay::Charge.include UsageCredits::PayChargeExtension
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
initializer "usage_credits.configs" do
|
34
|
+
# Initialize any config settings
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Centralized credit calculations used throughout the gem.
|
5
|
+
# This ensures consistent rounding and credit math everywhere.
|
6
|
+
module CreditCalculator
|
7
|
+
module_function
|
8
|
+
|
9
|
+
# Apply the configured rounding strategy to a credit amount
|
10
|
+
# Always defaults to ceiling to ensure we never undercharge
|
11
|
+
def apply_rounding(amount)
|
12
|
+
case UsageCredits.configuration.rounding_strategy
|
13
|
+
when :round
|
14
|
+
amount.round
|
15
|
+
when :floor
|
16
|
+
amount.floor
|
17
|
+
when :ceil
|
18
|
+
amount.ceil
|
19
|
+
else
|
20
|
+
amount.ceil # Default to ceiling to never undercharge
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Convert a monetary amount to credits
|
25
|
+
def money_to_credits(cents, exchange_rate)
|
26
|
+
apply_rounding(cents * exchange_rate / 100.0)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Convert credits to a monetary amount
|
30
|
+
def credits_to_money(credits, exchange_rate)
|
31
|
+
apply_rounding(credits * 100.0 / exchange_rate)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|