wallets 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.
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ begin
2
+ require "bundler/setup"
3
+ rescue LoadError
4
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
5
+ end
6
+
7
+ require "bundler/gem_tasks"
8
+
9
+ require "rdoc/task"
10
+
11
+ RDoc::Task.new(:rdoc) do |rdoc|
12
+ rdoc.rdoc_dir = "rdoc"
13
+ rdoc.title = "Wallets"
14
+ rdoc.options << "--line-numbers"
15
+ rdoc.rdoc_files.include("README.md")
16
+ rdoc.rdoc_files.include("lib/**/*.rb")
17
+ end
18
+
19
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
20
+ load "rails/tasks/engine.rake"
21
+
22
+ require "rake/testtask"
23
+
24
+ Rake::TestTask.new(:test) do |t|
25
+ t.libs << "test"
26
+ t.pattern = "test/**/*_test.rb"
27
+ t.verbose = false
28
+ end
29
+
30
+ task default: :test
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Wallets
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dir)
14
+ ActiveRecord::Generators::Base.next_migration_number(dir)
15
+ end
16
+
17
+ def create_migration_file
18
+ migration_template "create_wallets_tables.rb.erb", File.join(db_migrate_path, "create_wallets_tables.rb")
19
+ end
20
+
21
+ def create_initializer
22
+ template "initializer.rb", "config/initializers/wallets.rb"
23
+ end
24
+
25
+ def display_post_install_message
26
+ say "\n🎉 The `wallets` gem has been installed.", :green
27
+ say "\nTo complete the setup:"
28
+ say " 1. Run 'rails db:migrate' to create the wallet tables."
29
+ say " ⚠️ If you want a custom table prefix, set config.table_prefix in config/initializers/wallets.rb before migrating.", :yellow
30
+ say " 2. Add 'has_wallets' to any model that should own wallets."
31
+ say " 3. Adjust config/initializers/wallets.rb for your default asset, categories, and callbacks."
32
+ say " 4. Use owner.wallet(:asset_code) to start crediting, debiting, and transferring value."
33
+ say "\nYou now have an append-only wallet ledger with balances, allocations, and transfers.\n", :green
34
+ end
35
+
36
+ private
37
+
38
+ def migration_version
39
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ create_table wallets_table, id: primary_key_type do |t|
8
+ t.references :owner, polymorphic: true, null: false, type: foreign_key_type
9
+ t.string :asset_code, null: false
10
+ t.bigint :balance, null: false, default: 0
11
+ t.send(json_column_type, :metadata, null: false, default: json_column_default)
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index wallets_table, [:owner_type, :owner_id, :asset_code], unique: true, name: index_name("wallets_on_owner_and_asset_code")
17
+
18
+ create_table transfers_table, id: primary_key_type do |t|
19
+ t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: wallets_table }
20
+ t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: wallets_table }
21
+ t.string :asset_code, null: false
22
+ t.bigint :amount, null: false
23
+ t.string :category, null: false, default: "transfer"
24
+ t.string :expiration_policy, null: false, default: "preserve"
25
+ t.send(json_column_type, :metadata, null: false, default: json_column_default)
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ create_table transactions_table, id: primary_key_type do |t|
31
+ t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: wallets_table }
32
+ t.bigint :amount, null: false
33
+ t.string :category, null: false
34
+ t.datetime :expires_at
35
+ t.references :transfer, type: foreign_key_type, foreign_key: { to_table: transfers_table }
36
+ t.send(json_column_type, :metadata, null: false, default: json_column_default)
37
+
38
+ t.timestamps
39
+ end
40
+
41
+ # Allocations are the basis for the bucket-based, FIFO inventory system.
42
+ create_table allocations_table, id: primary_key_type do |t|
43
+ # The negative spend transaction that is consuming value.
44
+ t.references :transaction, null: false, type: foreign_key_type,
45
+ foreign_key: { to_table: transactions_table },
46
+ index: { name: index_name("allocations_on_transaction_id") }
47
+
48
+ # The positive source transaction the value is being consumed from.
49
+ t.references :source_transaction, null: false, type: foreign_key_type,
50
+ foreign_key: { to_table: transactions_table },
51
+ index: { name: index_name("allocations_on_source_transaction_id") }
52
+
53
+ t.bigint :amount, null: false
54
+
55
+ t.timestamps
56
+ end
57
+
58
+ add_index transactions_table, :category
59
+ add_index transactions_table, :expires_at
60
+ add_index transactions_table, [:wallet_id, :amount], name: index_name("transactions_on_wallet_id_and_amount")
61
+ add_index transactions_table, [:expires_at, :id], name: index_name("transactions_on_expires_at_and_id")
62
+ add_index allocations_table, [:transaction_id, :source_transaction_id], name: index_name("allocations_on_tx_and_source_tx")
63
+ add_index transfers_table, [:from_wallet_id, :to_wallet_id, :asset_code], name: index_name("transfers_on_wallets_and_asset")
64
+ end
65
+
66
+ private
67
+
68
+ def primary_and_foreign_key_types
69
+ config = Rails.configuration.generators
70
+ setting = config.options[config.orm][:primary_key_type]
71
+ primary_key_type = setting || :primary_key
72
+ foreign_key_type = setting || :bigint
73
+ [primary_key_type, foreign_key_type]
74
+ end
75
+
76
+ def json_column_type
77
+ return :jsonb if connection.adapter_name.downcase.include?("postgresql")
78
+
79
+ :json
80
+ end
81
+
82
+ # MySQL does not allow defaults on JSON columns, so models treat nil metadata
83
+ # the same as an empty hash when records are loaded.
84
+ def json_column_default
85
+ return nil if connection.adapter_name.downcase.include?("mysql")
86
+
87
+ {}
88
+ end
89
+
90
+ def table_prefix
91
+ Wallets.configuration.table_prefix
92
+ end
93
+
94
+ def wallets_table
95
+ "#{table_prefix}wallets"
96
+ end
97
+
98
+ def transactions_table
99
+ "#{table_prefix}transactions"
100
+ end
101
+
102
+ def allocations_table
103
+ "#{table_prefix}allocations"
104
+ end
105
+
106
+ def transfers_table
107
+ "#{table_prefix}transfers"
108
+ end
109
+
110
+ def index_name(suffix)
111
+ "index_#{table_prefix}#{suffix}"
112
+ end
113
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ Wallets.configure do |config|
4
+ # The asset returned by owner.wallet with no argument.
5
+ # Common examples:
6
+ # - :credits for usage-based apps
7
+ # - :coins or :gems for games
8
+ # - :eur or :usd for marketplace balances
9
+ # - :wood for resource-based economies
10
+ config.default_asset = :credits
11
+
12
+ # Prefix for the generated tables.
13
+ #
14
+ # Set this BEFORE running rails db:migrate for the first time.
15
+ # Treat it as permanent once the wallet tables exist.
16
+ #
17
+ # config.table_prefix = "wallets_"
18
+
19
+ # Set to true only if your domain explicitly supports debt or overdrafts.
20
+ #
21
+ # config.allow_negative_balance = false
22
+
23
+ # Optional threshold that fires on_low_balance_reached when crossed.
24
+ # Set to nil to disable.
25
+ #
26
+ # config.low_balance_threshold = 100
27
+
28
+ # Extra business event labels so the ledger stays readable.
29
+ # These extend the built-in defaults:
30
+ # :credit, :debit, :transfer, :expiration, :adjustment
31
+ #
32
+ # config.additional_categories = %w[
33
+ # ride_fare
34
+ # seller_payout
35
+ # reward_redemption
36
+ # marketplace_sale
37
+ # quest_reward
38
+ # resource_gathered
39
+ # ]
40
+ #
41
+ #
42
+ # === Lifecycle Callbacks ===
43
+ #
44
+ # Hook into wallet events for analytics, notifications, and custom logic.
45
+ # All callbacks receive a context object with event-specific data.
46
+ #
47
+ # Available callbacks:
48
+ # on_balance_credited - After value is added to a wallet
49
+ # on_balance_debited - After value is deducted from a wallet
50
+ # on_transfer_completed - After a transfer between wallets succeeds
51
+ # on_low_balance_reached - When balance drops below the threshold
52
+ # on_balance_depleted - When balance reaches exactly zero
53
+ # on_insufficient_balance - When a debit or transfer is rejected
54
+ #
55
+ # Context object properties (available depending on event):
56
+ # ctx.event # Symbol - the event name
57
+ # ctx.owner # The wallet owner (User, Team, Guild, etc.)
58
+ # ctx.wallet # The Wallets::Wallet instance
59
+ # ctx.amount # Balance involved
60
+ # ctx.previous_balance # Balance before the operation
61
+ # ctx.new_balance # Balance after the operation
62
+ # ctx.transaction # The Wallets::Transaction record
63
+ # ctx.transfer # The Wallets::Transfer record
64
+ # ctx.category # Transaction category
65
+ # ctx.threshold # Low balance threshold
66
+ # ctx.metadata # Additional event-specific context
67
+ # ctx.to_h # Hash representation without nil values
68
+ #
69
+ # IMPORTANT: Keep callbacks fast. Use background jobs for email,
70
+ # analytics, or anything that should not block balance operations.
71
+ #
72
+ # config.on_balance_credited do |ctx|
73
+ # Rails.logger.info "[Wallets] Credited #{ctx.amount} to #{ctx.owner.class}##{ctx.owner.id}"
74
+ # end
75
+ #
76
+ # config.on_balance_debited do |ctx|
77
+ # Rails.logger.info "[Wallets] Debited #{ctx.amount} from #{ctx.owner.class}##{ctx.owner.id}"
78
+ # end
79
+ #
80
+ # config.on_transfer_completed do |ctx|
81
+ # Rails.logger.info "[Wallets] Transfer #{ctx.transfer.id} completed"
82
+ # end
83
+ #
84
+ # config.on_insufficient_balance do |ctx|
85
+ # Rails.logger.info "[Wallets] #{ctx.owner.class}##{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
86
+ # end
87
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Immutable event payload passed to lifecycle callbacks.
5
+ # Keeping callback data in one object makes it easier to extend callback APIs
6
+ # without breaking existing handlers.
7
+ CallbackContext = Struct.new(
8
+ :event,
9
+ :wallet,
10
+ :transfer,
11
+ :amount,
12
+ :previous_balance,
13
+ :new_balance,
14
+ :threshold,
15
+ :category,
16
+ :transaction,
17
+ :metadata,
18
+ keyword_init: true
19
+ ) do
20
+ def owner
21
+ wallet&.owner
22
+ end
23
+
24
+ def to_h
25
+ super.compact
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Centralized callback dispatcher with error isolation.
5
+ # Callback failures should never break the ledger write that triggered them.
6
+ module Callbacks
7
+ module_function
8
+
9
+ def dispatch(event, **context_data)
10
+ callback = Wallets.configuration.public_send(:"on_#{event}_callback")
11
+ return unless callback.is_a?(Proc)
12
+
13
+ context = CallbackContext.new(event: event, **context_data)
14
+ execute_safely(callback, context)
15
+ end
16
+
17
+ def execute_safely(callback, context)
18
+ case callback.arity
19
+ when 1, -1, -2
20
+ callback.call(context)
21
+ when 0
22
+ callback.call
23
+ else
24
+ log_warn "[Wallets] Callback has unexpected arity (#{callback.arity}). Expected 0 or 1."
25
+ end
26
+ rescue StandardError => e
27
+ log_error "[Wallets] Callback error for #{context.event}: #{e.class}: #{e.message}"
28
+ log_debug e.backtrace.join("\n")
29
+ end
30
+
31
+ def log_error(message)
32
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
33
+ Rails.logger.error(message)
34
+ else
35
+ warn message
36
+ end
37
+ end
38
+
39
+ def log_warn(message)
40
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
41
+ Rails.logger.warn(message)
42
+ else
43
+ warn message
44
+ end
45
+ end
46
+
47
+ def log_debug(message)
48
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
49
+ Rails.logger.debug(message)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Configuration for the Wallets gem. This is the single source of truth for
5
+ # the wallet owner API, ledger callbacks, and installation-time table names.
6
+ class Configuration
7
+ # =========================================
8
+ # Basic Settings
9
+ # =========================================
10
+
11
+ attr_accessor :allow_negative_balance
12
+ attr_reader :default_asset, :additional_categories, :table_prefix
13
+ attr_reader :low_balance_threshold
14
+ attr_reader :transfer_expiration_policy
15
+
16
+ # =========================================
17
+ # Lifecycle Callbacks
18
+ # =========================================
19
+
20
+ attr_reader :on_balance_credited_callback,
21
+ :on_balance_debited_callback,
22
+ :on_transfer_completed_callback,
23
+ :on_low_balance_reached_callback,
24
+ :on_balance_depleted_callback,
25
+ :on_insufficient_balance_callback
26
+
27
+ def initialize
28
+ # Keep the out-of-the-box default close to the most common "main wallet"
29
+ # use case while still allowing apps to override it immediately.
30
+ @default_asset = :credits
31
+ @additional_categories = []
32
+ @allow_negative_balance = false
33
+ @low_balance_threshold = nil
34
+ @transfer_expiration_policy = :preserve
35
+ # This prefix is used by the models at runtime and by the install
36
+ # migration when it is executed for the first time.
37
+ @table_prefix = "wallets_"
38
+
39
+ @on_balance_credited_callback = nil
40
+ @on_balance_debited_callback = nil
41
+ @on_transfer_completed_callback = nil
42
+ @on_low_balance_reached_callback = nil
43
+ @on_balance_depleted_callback = nil
44
+ @on_insufficient_balance_callback = nil
45
+ end
46
+
47
+ def default_asset=(value)
48
+ value = normalize_asset_code(value)
49
+ raise ArgumentError, "Default asset can't be blank" if value.blank?
50
+
51
+ @default_asset = value.to_sym
52
+ end
53
+
54
+ def additional_categories=(categories)
55
+ raise ArgumentError, "Additional categories must be an array" unless categories.is_a?(Array)
56
+
57
+ @additional_categories = categories.map { |category| normalize_category(category) }.reject(&:blank?).uniq
58
+ end
59
+
60
+ def low_balance_threshold=(value)
61
+ if value
62
+ value = Integer(value)
63
+ raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative?
64
+ end
65
+
66
+ @low_balance_threshold = value
67
+ end
68
+
69
+ def table_prefix=(value)
70
+ value = value.to_s
71
+ raise ArgumentError, "Table prefix can't be blank" if value.blank?
72
+
73
+ @table_prefix = value
74
+ end
75
+
76
+ def transfer_expiration_policy=(value)
77
+ normalized_value = value.to_s.strip.downcase.to_sym
78
+ allowed_values = %i[preserve none]
79
+
80
+ raise ArgumentError, "Transfer expiration policy must be one of: #{allowed_values.join(', ')}" unless allowed_values.include?(normalized_value)
81
+
82
+ @transfer_expiration_policy = normalized_value
83
+ end
84
+
85
+ def on_balance_credited(&block)
86
+ @on_balance_credited_callback = block
87
+ end
88
+
89
+ def on_balance_debited(&block)
90
+ @on_balance_debited_callback = block
91
+ end
92
+
93
+ def on_transfer_completed(&block)
94
+ @on_transfer_completed_callback = block
95
+ end
96
+
97
+ def on_low_balance_reached(&block)
98
+ @on_low_balance_reached_callback = block
99
+ end
100
+
101
+ def on_balance_depleted(&block)
102
+ @on_balance_depleted_callback = block
103
+ end
104
+
105
+ def on_insufficient_balance(&block)
106
+ @on_insufficient_balance_callback = block
107
+ end
108
+
109
+ private
110
+
111
+ def normalize_asset_code(value)
112
+ value.to_s.strip.downcase
113
+ end
114
+
115
+ def normalize_category(value)
116
+ value.to_s.strip
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Wallets
6
+
7
+ # Load wallet models early so host apps can reference them during boot.
8
+ config.autoload_paths << File.expand_path("models", __dir__)
9
+ config.autoload_paths << File.expand_path("models/concerns", __dir__)
10
+
11
+ initializer "wallets.autoload", before: :set_autoload_paths do |app|
12
+ app.config.autoload_paths << root.join("lib")
13
+ app.config.autoload_paths << root.join("lib/wallets/models")
14
+ app.config.autoload_paths << root.join("lib/wallets/models/concerns")
15
+ end
16
+
17
+ initializer "wallets.active_record" do
18
+ ActiveSupport.on_load(:active_record) do
19
+ extend Wallets::HasWallets::ClassMethods
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Allocations link a negative spend transaction to the positive transactions it
5
+ # consumed from. This is what makes FIFO spending and expiration-aware balances
6
+ # possible without mutating historical transactions.
7
+ #
8
+ # This class supports embedding: subclasses can override config and table
9
+ # names without affecting the base Wallets::* behavior.
10
+ class Allocation < ApplicationRecord
11
+ class_attribute :embedded_table_name, default: nil
12
+ class_attribute :config_provider, default: -> { Wallets.configuration }
13
+
14
+ def self.table_name
15
+ embedded_table_name || "#{resolved_config.table_prefix}allocations"
16
+ end
17
+
18
+ def self.resolved_config
19
+ value = config_provider
20
+ value.respond_to?(:call) ? value.call : value
21
+ end
22
+
23
+ belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id"
24
+ belongs_to :source_transaction, class_name: "Wallets::Transaction"
25
+
26
+ validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
27
+ validate :source_transaction_has_matching_asset
28
+ validate :allocation_does_not_exceed_remaining_amount
29
+
30
+ private
31
+
32
+ def source_transaction_has_matching_asset
33
+ return if spend_transaction.blank? || source_transaction.blank?
34
+ return if spend_transaction.wallet_id == source_transaction.wallet_id
35
+
36
+ errors.add(:source_transaction, "must belong to the same wallet as the spend transaction")
37
+ end
38
+
39
+ def allocation_does_not_exceed_remaining_amount
40
+ return if amount.blank? || source_transaction.blank?
41
+
42
+ if source_transaction.remaining_amount < amount
43
+ errors.add(:amount, "exceeds the remaining amount of the source transaction")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Adds multi-wallet ownership to an Active Record model.
5
+ # One owner can have one wallet per asset code, with a default "main wallet"
6
+ # exposed via `owner.wallet` and `owner.main_wallet`.
7
+ module HasWallets
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ def has_wallets(**options)
12
+ include Wallets::HasWallets unless included_modules.include?(Wallets::HasWallets)
13
+
14
+ @wallet_options = {
15
+ default_asset: Wallets.configuration.default_asset,
16
+ auto_create: true,
17
+ initial_balance: 0
18
+ }.merge(options)
19
+ end
20
+
21
+ def wallet_options
22
+ @wallet_options ||= {
23
+ default_asset: Wallets.configuration.default_asset,
24
+ auto_create: true,
25
+ initial_balance: 0
26
+ }
27
+ end
28
+ end
29
+
30
+ included do
31
+ has_many :wallets,
32
+ class_name: "Wallets::Wallet",
33
+ as: :owner,
34
+ dependent: :destroy
35
+
36
+ after_create :create_main_wallet, if: :should_auto_create_wallet?
37
+ end
38
+
39
+ def wallet_options
40
+ self.class.wallet_options
41
+ end
42
+
43
+ def wallet(asset_code = nil)
44
+ ensure_wallet(asset_code || wallet_options[:default_asset])
45
+ end
46
+
47
+ def wallet?(asset_code = nil)
48
+ find_wallet(asset_code || wallet_options[:default_asset]).present?
49
+ end
50
+
51
+ def main_wallet
52
+ wallet(wallet_options[:default_asset])
53
+ end
54
+
55
+ def find_wallet(asset_code = nil)
56
+ normalized_asset_code = normalize_asset_code(asset_code || wallet_options[:default_asset])
57
+ wallets.find_by(asset_code: normalized_asset_code)
58
+ end
59
+
60
+ private
61
+
62
+ def should_auto_create_wallet?
63
+ wallet_options[:auto_create] != false
64
+ end
65
+
66
+ def ensure_wallet(asset_code)
67
+ existing_wallet = find_wallet(asset_code)
68
+ return existing_wallet if existing_wallet.present?
69
+ return unless should_auto_create_wallet?
70
+ raise "Cannot create wallet for unsaved owner" unless persisted?
71
+
72
+ Wallet.create_for_owner!(
73
+ owner: self,
74
+ asset_code: asset_code,
75
+ initial_balance: initial_balance_for(asset_code)
76
+ )
77
+ end
78
+
79
+ def create_main_wallet
80
+ main_wallet
81
+ end
82
+
83
+ def normalize_asset_code(value)
84
+ value.to_s.strip.downcase
85
+ end
86
+
87
+ def initial_balance_for(asset_code)
88
+ return 0 unless normalize_asset_code(asset_code) == normalize_asset_code(wallet_options[:default_asset])
89
+
90
+ wallet_options[:initial_balance] || 0
91
+ end
92
+ end
93
+ end