wallets 0.1.0 → 0.4.1

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.
@@ -30,7 +30,7 @@ module Wallets
30
30
  say " 2. Add 'has_wallets' to any model that should own wallets."
31
31
  say " 3. Adjust config/initializers/wallets.rb for your default asset, categories, and callbacks."
32
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
33
+ say "\nYou now have an append-oriented wallet ledger with balances, allocations, and transfers.\n", :green
34
34
  end
35
35
 
36
36
  private
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds holds and reservations (wallets 0.4) to an existing wallets 0.1, 0.2
4
+ # or 0.3 installation: the holds table, and the wallets' ledger revision.
5
+ class AddWalletsHolds < ActiveRecord::Migration<%= migration_version %>
6
+ def up
7
+ # Incremented by every command, so a stale database snapshot can always
8
+ # be told from a current one, whatever the clock or a `touch` says.
9
+ unless column_exists?(wallets_table, :ledger_revision)
10
+ add_column wallets_table, :ledger_revision, :bigint, null: false, default: 0
11
+ end
12
+
13
+ # An app that installed 0.4 directly already has the table.
14
+ return if table_exists?(holds_table)
15
+
16
+ # Holds keep value in the ledger but out of the available balance: a hold
17
+ # on one credit, a hold on the whole wallet, or a reservation of an amount.
18
+ create_table holds_table, id: holds_primary_key_type do |t|
19
+ t.references :wallet, null: false, **key_type_of(wallets_table),
20
+ foreign_key: { to_table: wallets_table },
21
+ index: false
22
+
23
+ # The credit a credit hold covers. Wallet holds and reservations have none.
24
+ t.references :transaction, **key_type_of(transactions_table),
25
+ foreign_key: { to_table: transactions_table },
26
+ index: false
27
+
28
+ # The debit a reservation was captured into.
29
+ t.references :capture_transaction, **key_type_of(transactions_table),
30
+ foreign_key: { to_table: transactions_table },
31
+ index: { unique: true, name: index_name("holds_on_capture_tx") }
32
+
33
+ t.bigint :amount # reservations only
34
+ t.string :category, null: false
35
+ t.datetime :releases_at # the scheduled end
36
+ t.datetime :released_at # the explicit end: a release or a capture
37
+ t.string :release_reason
38
+ t.send(json_column_type, :metadata, null: false, default: json_column_default)
39
+ t.send(json_column_type, :release_metadata, null: false, default: json_column_default)
40
+
41
+ t.timestamps
42
+
43
+ # Ordinary composite indexes, which every adapter supports; correctness
44
+ # never depends on a partial index. They also serve the wallet_id and
45
+ # transaction_id foreign keys, and live inside the table so that MySQL
46
+ # can drop the table without first dropping an index a key still needs.
47
+ t.index [:wallet_id, :released_at], name: index_name("holds_on_wallet_and_released_at")
48
+ t.index [:transaction_id, :released_at], name: index_name("holds_on_tx_and_released_at")
49
+ t.index :releases_at, name: index_name("holds_on_releases_at")
50
+
51
+ t.check_constraint "amount IS NULL OR amount > 0", name: constraint_name("holds_amount_positive")
52
+ # A credit hold holds the credit's whole remaining value, never an amount.
53
+ t.check_constraint "transaction_id IS NULL OR amount IS NULL", name: constraint_name("holds_credit_hold_has_no_amount")
54
+ # Only an ended reservation can name the debit it was captured into.
55
+ t.check_constraint "capture_transaction_id IS NULL OR " \
56
+ "(transaction_id IS NULL AND amount IS NOT NULL AND released_at IS NOT NULL)",
57
+ name: constraint_name("holds_capture_ends_reservation")
58
+ end
59
+ end
60
+
61
+ # Rolling back must never delete holds, including holds this migration did
62
+ # not create (its `up` is a no-op when the table already exists). An empty
63
+ # table goes, and the ledger revision with it: the schema is 0.3's again.
64
+ # A table with rows stays, and says so.
65
+ def down
66
+ return unless table_exists?(holds_table)
67
+
68
+ if select_value("SELECT 1 FROM #{quote_table_name(holds_table)} LIMIT 1")
69
+ raise ActiveRecord::IrreversibleMigration,
70
+ "#{holds_table} still has holds, and rolling back would delete them. " \
71
+ "Drop the table deliberately if that is what you mean."
72
+ end
73
+
74
+ drop_table holds_table
75
+ remove_column wallets_table, :ledger_revision if column_exists?(wallets_table, :ledger_revision)
76
+ end
77
+
78
+ private
79
+
80
+ # Key types come from the tables that already exist, read when the migration
81
+ # runs, never from today's generator settings: the ledger may predate them,
82
+ # and MySQL refuses a foreign key whose column differs from the key it
83
+ # references (int against bigint, signed against unsigned).
84
+ def key_type_of(table)
85
+ key = primary_key_column(table)
86
+ options = { type: key.type }
87
+
88
+ # An integer key keeps its exact width. SQLite reports none: its rowid
89
+ # keys are 64-bit, which is what the install template declares.
90
+ if key.type == :integer && key.limit
91
+ options[:limit] = key.limit
92
+ elsif key.type == :integer
93
+ options[:type] = :bigint
94
+ end
95
+ options[:unsigned] = true if key.respond_to?(:unsigned?) && key.unsigned?
96
+
97
+ options
98
+ end
99
+
100
+ # The holds table's own key follows the ledger's: uuid for a uuid ledger,
101
+ # the adapter's default auto-increment key for an integer one.
102
+ def holds_primary_key_type
103
+ key = primary_key_column(wallets_table)
104
+ key.type == :integer ? :primary_key : key.type
105
+ end
106
+
107
+ def primary_key_column(table)
108
+ primary_key = connection.primary_key(table)
109
+ connection.columns(table).find { |column| column.name == primary_key }
110
+ end
111
+
112
+ def json_column_type
113
+ return :jsonb if connection.adapter_name.downcase.include?("postgresql")
114
+
115
+ :json
116
+ end
117
+
118
+ # MySQL does not allow defaults on JSON columns, so models treat nil metadata
119
+ # the same as an empty hash when records are loaded. Rails' Trilogy adapter
120
+ # talks to MySQL too, under its own name.
121
+ def json_column_default
122
+ return nil if connection.adapter_name.match?(/mysql|trilogy/i)
123
+
124
+ {}
125
+ end
126
+
127
+ def table_prefix
128
+ Wallets.configuration.table_prefix
129
+ end
130
+
131
+ def wallets_table
132
+ "#{table_prefix}wallets"
133
+ end
134
+
135
+ def transactions_table
136
+ "#{table_prefix}transactions"
137
+ end
138
+
139
+ def holds_table
140
+ "#{table_prefix}holds"
141
+ end
142
+
143
+ def index_name(suffix)
144
+ "index_#{table_prefix}#{suffix}"
145
+ end
146
+
147
+ def constraint_name(suffix)
148
+ "check_#{table_prefix}#{suffix}"
149
+ end
150
+ end
@@ -8,6 +8,9 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
8
8
  t.references :owner, polymorphic: true, null: false, type: foreign_key_type
9
9
  t.string :asset_code, null: false
10
10
  t.bigint :balance, null: false, default: 0
11
+ # Incremented by every command, so a stale database snapshot can always
12
+ # be told from a current one, whatever the clock or a `touch` says.
13
+ t.bigint :ledger_revision, null: false, default: 0
11
14
  t.send(json_column_type, :metadata, null: false, default: json_column_default)
12
15
 
13
16
  t.timestamps
@@ -38,7 +41,7 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
38
41
  t.timestamps
39
42
  end
40
43
 
41
- # Allocations are the basis for the bucket-based, FIFO inventory system.
44
+ # Allocations are the basis for the bucket-based, first-expiring-first-out inventory system.
42
45
  create_table allocations_table, id: primary_key_type do |t|
43
46
  # The negative spend transaction that is consuming value.
44
47
  t.references :transaction, null: false, type: foreign_key_type,
@@ -55,6 +58,63 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
55
58
  t.timestamps
56
59
  end
57
60
 
61
+ # Holds keep value in the ledger but out of the available balance: a hold
62
+ # on one credit, a hold on the whole wallet, or a reservation of an amount.
63
+ create_table holds_table, id: primary_key_type do |t|
64
+ t.references :wallet, null: false, type: foreign_key_type,
65
+ foreign_key: { to_table: wallets_table },
66
+ index: false
67
+
68
+ # The credit a credit hold covers. Wallet holds and reservations have none.
69
+ t.references :transaction, type: foreign_key_type,
70
+ foreign_key: { to_table: transactions_table },
71
+ index: false
72
+
73
+ # The debit a reservation was captured into.
74
+ t.references :capture_transaction, type: foreign_key_type,
75
+ foreign_key: { to_table: transactions_table },
76
+ index: { unique: true, name: index_name("holds_on_capture_tx") }
77
+
78
+ t.bigint :amount # reservations only
79
+ t.string :category, null: false
80
+ t.datetime :releases_at # the scheduled end
81
+ t.datetime :released_at # the explicit end: a release or a capture
82
+ t.string :release_reason
83
+ t.send(json_column_type, :metadata, null: false, default: json_column_default)
84
+ t.send(json_column_type, :release_metadata, null: false, default: json_column_default)
85
+
86
+ t.timestamps
87
+
88
+ # Ordinary composite indexes, which every adapter supports; correctness
89
+ # never depends on a partial index. They also serve the wallet_id and
90
+ # transaction_id foreign keys, and live inside the table so that MySQL
91
+ # can drop the table without first dropping an index a key still needs.
92
+ t.index [:wallet_id, :released_at], name: index_name("holds_on_wallet_and_released_at")
93
+ t.index [:transaction_id, :released_at], name: index_name("holds_on_tx_and_released_at")
94
+ t.index :releases_at, name: index_name("holds_on_releases_at")
95
+
96
+ t.check_constraint "amount IS NULL OR amount > 0", name: constraint_name("holds_amount_positive")
97
+ # A credit hold holds the credit's whole remaining value, never an amount.
98
+ t.check_constraint "transaction_id IS NULL OR amount IS NULL", name: constraint_name("holds_credit_hold_has_no_amount")
99
+ # Only an ended reservation can name the debit it was captured into.
100
+ t.check_constraint "capture_transaction_id IS NULL OR " \
101
+ "(transaction_id IS NULL AND amount IS NOT NULL AND released_at IS NOT NULL)",
102
+ name: constraint_name("holds_capture_ends_reservation")
103
+ end
104
+
105
+ add_check_constraint transfers_table,
106
+ "amount > 0",
107
+ name: constraint_name("transfers_amount_positive")
108
+ add_check_constraint transfers_table,
109
+ "from_wallet_id <> to_wallet_id",
110
+ name: constraint_name("transfers_distinct_wallets")
111
+ add_check_constraint transactions_table,
112
+ "amount <> 0",
113
+ name: constraint_name("transactions_amount_nonzero")
114
+ add_check_constraint allocations_table,
115
+ "amount > 0",
116
+ name: constraint_name("allocations_amount_positive")
117
+
58
118
  add_index transactions_table, :category
59
119
  add_index transactions_table, :expires_at
60
120
  add_index transactions_table, [:wallet_id, :amount], name: index_name("transactions_on_wallet_id_and_amount")
@@ -80,9 +140,10 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
80
140
  end
81
141
 
82
142
  # MySQL does not allow defaults on JSON columns, so models treat nil metadata
83
- # the same as an empty hash when records are loaded.
143
+ # the same as an empty hash when records are loaded. Rails' Trilogy adapter
144
+ # talks to MySQL too, under its own name.
84
145
  def json_column_default
85
- return nil if connection.adapter_name.downcase.include?("mysql")
146
+ return nil if connection.adapter_name.match?(/mysql|trilogy/i)
86
147
 
87
148
  {}
88
149
  end
@@ -107,7 +168,15 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
107
168
  "#{table_prefix}transfers"
108
169
  end
109
170
 
171
+ def holds_table
172
+ "#{table_prefix}holds"
173
+ end
174
+
110
175
  def index_name(suffix)
111
176
  "index_#{table_prefix}#{suffix}"
112
177
  end
178
+
179
+ def constraint_name(suffix)
180
+ "check_#{table_prefix}#{suffix}"
181
+ end
113
182
  end
@@ -17,6 +17,15 @@ Wallets.configure do |config|
17
17
  # config.table_prefix = "wallets_"
18
18
 
19
19
  # Set to true only if your domain explicitly supports debt or overdrafts.
20
+ # Applies consistently to `debit`, `transfer_to`, `reserve` and `capture!`:
21
+ # each may then go beyond the available balance. Cap how negative a
22
+ # wallet can go in your own service layer (the flag is binary).
23
+ #
24
+ # The flag decides what commands may do, not what the balance may read.
25
+ # With it off, the available balance can still go negative when a hold
26
+ # covers value that is also reserved, or when reserved value expires.
27
+ # Such wallets stay saveable: they take credits and releases, and refuse
28
+ # new spending, so turning the flag off is safe at any time.
20
29
  #
21
30
  # config.allow_negative_balance = false
22
31
 
@@ -27,7 +36,8 @@ Wallets.configure do |config|
27
36
 
28
37
  # Extra business event labels so the ledger stays readable.
29
38
  # These extend the built-in defaults:
30
- # :credit, :debit, :transfer, :expiration, :adjustment
39
+ # :credit, :debit, :transfer_in, :transfer_out, :refund,
40
+ # :reward, :purchase, :top_up, :adjustment
31
41
  #
32
42
  # config.additional_categories = %w[
33
43
  # ride_fare
@@ -48,22 +58,38 @@ Wallets.configure do |config|
48
58
  # on_balance_credited - After value is added to a wallet
49
59
  # on_balance_debited - After value is deducted from a wallet
50
60
  # 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
61
+ # on_low_balance_reached - When the available balance drops to the
62
+ # threshold or below (one-shot per crossing)
63
+ # on_balance_depleted - When the available balance drops from
64
+ # positive to zero or below: a debit, a hold
65
+ # or a reservation (one-shot per crossing)
66
+ # on_insufficient_balance - When a debit, transfer, reservation or
67
+ # capture is refused for lack of funds
68
+ # on_hold_created - After a hold or a reservation is placed
69
+ # on_hold_released - After hold.release! or capture! ends a hold
70
+ # (a deadline passing is computed, never
71
+ # announced)
72
+ #
73
+ # Balances work like a bank statement: wallet.balance is the AVAILABLE
74
+ # balance (what can be spent now), wallet.held_balance is what holds and
75
+ # reservations keep pending, and wallet.total_balance is both together.
76
+ # previous_balance and new_balance always report the available balance.
54
77
  #
55
78
  # Context object properties (available depending on event):
56
79
  # ctx.event # Symbol - the event name
57
80
  # ctx.owner # The wallet owner (User, Team, Guild, etc.)
58
81
  # 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
82
+ # ctx.amount # Amount involved (a reservation's, on hold events)
83
+ # ctx.previous_balance # Available balance before the operation
84
+ # ctx.new_balance # Available balance after the operation
85
+ # ctx.transaction # The Wallets::Transaction record (a capture's debit
86
+ # # on hold_released)
63
87
  # ctx.transfer # The Wallets::Transfer record
64
- # ctx.category # Transaction category
88
+ # ctx.hold # The Wallets::Hold record (hold events)
89
+ # ctx.category # Transaction category (the hold's on hold events)
65
90
  # ctx.threshold # Low balance threshold
66
- # ctx.metadata # Additional event-specific context
91
+ # ctx.metadata # Additional event-specific context (a hold's
92
+ # # creation metadata, or its release metadata)
67
93
  # ctx.to_h # Hash representation without nil values
68
94
  #
69
95
  # IMPORTANT: Keep callbacks fast. Use background jobs for email,
@@ -84,4 +110,20 @@ Wallets.configure do |config|
84
110
  # config.on_insufficient_balance do |ctx|
85
111
  # Rails.logger.info "[Wallets] #{ctx.owner.class}##{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
86
112
  # end
113
+ #
114
+ # config.on_hold_created do |ctx|
115
+ # Rails.logger.info "[Wallets] #{ctx.category} hold on #{ctx.owner.class}##{ctx.owner.id}, available #{ctx.new_balance}"
116
+ # end
117
+ #
118
+ # on_hold_released carries the hold's category, its amount (reservations
119
+ # only) and the release metadata. On a capture, ctx.transaction is the
120
+ # debit the capture posted, which carries the capture's metadata.
121
+ #
122
+ # config.on_hold_released do |ctx|
123
+ # if ctx.transaction
124
+ # Rails.logger.info "[Wallets] #{ctx.category} reservation captured as transaction #{ctx.transaction.id}"
125
+ # else
126
+ # Rails.logger.info "[Wallets] #{ctx.category} hold released: #{ctx.hold.release_reason}"
127
+ # end
128
+ # end
87
129
  end
@@ -0,0 +1,76 @@
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
+ # Brings an existing installation's schema up to date. Today that is one
9
+ # migration: the holds table that 0.4 adds to 0.1, 0.2 and 0.3 installs.
10
+ class UpgradeGenerator < Rails::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ def self.next_migration_number(dir)
16
+ ActiveRecord::Generators::Base.next_migration_number(dir)
17
+ end
18
+
19
+ # Idempotent on purpose: a second run (even after a Rails upgrade has
20
+ # changed the rendered migration version) points at the migration it
21
+ # already wrote instead of adding a duplicate, and an app whose 0.4
22
+ # schema is already complete (a fresh 0.4 install: the holds table and
23
+ # the wallets' ledger revision) gets no migration at all, so it can
24
+ # never hold a rollback that would drop them.
25
+ def create_holds_migration
26
+ existing_migration = self.class.migration_exists?(File.expand_path(db_migrate_path, destination_root), "add_wallets_holds")
27
+
28
+ if existing_migration
29
+ say_status :exist, relative_to_original_destination_root(existing_migration), :blue
30
+ elsif upgraded_schema?
31
+ @skipped = true
32
+ say_status :skip, "#{holds_table} already exists: there is nothing to migrate", :yellow
33
+ else
34
+ migration_template "add_wallets_holds.rb.erb", File.join(db_migrate_path, "add_wallets_holds.rb")
35
+ end
36
+ end
37
+
38
+ def display_post_upgrade_message
39
+ if @skipped
40
+ say "\n✅ This app already has the `wallets` 0.4 holds table: nothing to migrate.", :green
41
+ say "\nRead the 0.4.0 CHANGELOG: `balance` is now the AVAILABLE balance, and held value is excluded from it."
42
+ return
43
+ end
44
+
45
+ say "\n🎉 The `wallets` upgrade migration is ready.", :green
46
+ say "\nTo complete the upgrade:"
47
+ say " 1. Run 'rails db:migrate' to add the holds table (a no-op if it already exists)."
48
+ say " 2. Read the 0.4.0 CHANGELOG: `balance` is now the AVAILABLE balance, and held value is excluded from it."
49
+ end
50
+
51
+ private
52
+
53
+ # Asks the database when it can. One that does not exist yet, or cannot
54
+ # be reached, gets the migration anyway: its `up` checks again.
55
+ def upgraded_schema?
56
+ ActiveRecord::Base.with_connection do |connection|
57
+ connection.table_exists?(holds_table) && connection.column_exists?(wallets_table, :ledger_revision)
58
+ end
59
+ rescue ActiveRecord::ActiveRecordError
60
+ false
61
+ end
62
+
63
+ def holds_table
64
+ "#{Wallets.configuration.table_prefix}holds"
65
+ end
66
+
67
+ def wallets_table
68
+ "#{Wallets.configuration.table_prefix}wallets"
69
+ end
70
+
71
+ def migration_version
72
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -15,6 +15,7 @@ module Wallets
15
15
  :category,
16
16
  :transaction,
17
17
  :metadata,
18
+ :hold,
18
19
  keyword_init: true
19
20
  ) do
20
21
  def owner
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Shared callback execution plumbing for wallets and embedding gems.
5
+ # Extenders provide callback_configuration, callback_context_class, and
6
+ # callback_log_prefix; arity handling, freezing, and error isolation live in
7
+ # one place.
8
+ module CallbackDispatcher
9
+ def dispatch(event, **context_data)
10
+ reader = :"on_#{event}_callback"
11
+ config = callback_configuration
12
+ return unless config.respond_to?(reader)
13
+
14
+ callback = config.public_send(reader)
15
+ return unless callback.is_a?(Proc)
16
+
17
+ context = callback_context_class.new(event: event, **context_data).freeze
18
+ execute_safely(callback, context)
19
+ end
20
+
21
+ def execute_safely(callback, context)
22
+ case callback.arity
23
+ when 1, -1, -2
24
+ callback.call(context)
25
+ when 0
26
+ callback.call
27
+ else
28
+ log_warn "#{callback_log_prefix} Callback has unexpected arity (#{callback.arity}). Expected 0 or 1."
29
+ end
30
+ rescue => e
31
+ log_error "#{callback_log_prefix} Callback error for #{context.event}: #{e.class}: #{e.message}"
32
+ log_debug Array(e.backtrace).join("\n")
33
+ end
34
+
35
+ def log_error(message)
36
+ rails_logger ? rails_logger.error(message) : warn(message)
37
+ end
38
+
39
+ def log_warn(message)
40
+ rails_logger ? rails_logger.warn(message) : warn(message)
41
+ end
42
+
43
+ def log_debug(message)
44
+ rails_logger.debug(message) if rails_logger&.debug?
45
+ end
46
+
47
+ def rails_logger
48
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
49
+ end
50
+ end
51
+ end
@@ -1,53 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wallets
4
- # Centralized callback dispatcher with error isolation.
5
- # Callback failures should never break the ledger write that triggered them.
4
+ # Wallet-specific adapter for the shared callback dispatcher.
6
5
  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
6
+ extend Wallets::CallbackDispatcher
16
7
 
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
8
+ module_function
30
9
 
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
10
+ def callback_configuration
11
+ Wallets.configuration
37
12
  end
38
13
 
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
14
+ def callback_context_class
15
+ Wallets::CallbackContext
45
16
  end
46
17
 
47
- def log_debug(message)
48
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
49
- Rails.logger.debug(message)
50
- end
18
+ def callback_log_prefix
19
+ "[Wallets]"
51
20
  end
52
21
  end
53
22
  end
@@ -18,11 +18,13 @@ module Wallets
18
18
  # =========================================
19
19
 
20
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
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
+ :on_hold_created_callback,
27
+ :on_hold_released_callback
26
28
 
27
29
  def initialize
28
30
  # Keep the out-of-the-box default close to the most common "main wallet"
@@ -42,10 +44,12 @@ module Wallets
42
44
  @on_low_balance_reached_callback = nil
43
45
  @on_balance_depleted_callback = nil
44
46
  @on_insufficient_balance_callback = nil
47
+ @on_hold_created_callback = nil
48
+ @on_hold_released_callback = nil
45
49
  end
46
50
 
47
51
  def default_asset=(value)
48
- value = normalize_asset_code(value)
52
+ value = Wallets.normalize_asset_code(value)
49
53
  raise ArgumentError, "Default asset can't be blank" if value.blank?
50
54
 
51
55
  @default_asset = value.to_sym
@@ -59,7 +63,7 @@ module Wallets
59
63
 
60
64
  def low_balance_threshold=(value)
61
65
  if value
62
- value = Integer(value)
66
+ value = Wallets::WholeNumber.parse(value, name: "Low balance threshold", allow_string: true)
63
67
  raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative?
64
68
  end
65
69
 
@@ -77,7 +81,7 @@ module Wallets
77
81
  normalized_value = value.to_s.strip.downcase.to_sym
78
82
  allowed_values = %i[preserve none]
79
83
 
80
- raise ArgumentError, "Transfer expiration policy must be one of: #{allowed_values.join(', ')}" unless allowed_values.include?(normalized_value)
84
+ raise ArgumentError, "Transfer expiration policy must be one of: #{allowed_values.join(", ")}" unless allowed_values.include?(normalized_value)
81
85
 
82
86
  @transfer_expiration_policy = normalized_value
83
87
  end
@@ -106,12 +110,16 @@ module Wallets
106
110
  @on_insufficient_balance_callback = block
107
111
  end
108
112
 
109
- private
113
+ def on_hold_created(&block)
114
+ @on_hold_created_callback = block
115
+ end
110
116
 
111
- def normalize_asset_code(value)
112
- value.to_s.strip.downcase
117
+ def on_hold_released(&block)
118
+ @on_hold_released_callback = block
113
119
  end
114
120
 
121
+ private
122
+
115
123
  def normalize_category(value)
116
124
  value.to_s.strip
117
125
  end
@@ -4,15 +4,11 @@ module Wallets
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Wallets
6
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
7
+ # All gem code is required eagerly by lib/wallets.rb, so nothing here is
8
+ # added to the host app's autoload paths. Registering lib/wallets/models
9
+ # with Zeitwerk would claim top-level constants like ::Wallet and
10
+ # ::Transaction and shadow (break) host apps that define models with
11
+ # those very common names.
16
12
 
17
13
  initializer "wallets.active_record" do
18
14
  ActiveSupport.on_load(:active_record) do