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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +803 -62
- data/lib/generators/wallets/install_generator.rb +1 -1
- data/lib/generators/wallets/templates/add_wallets_holds.rb.erb +150 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +72 -3
- data/lib/generators/wallets/templates/initializer.rb +52 -10
- data/lib/generators/wallets/upgrade_generator.rb +76 -0
- data/lib/wallets/callback_context.rb +1 -0
- data/lib/wallets/callback_dispatcher.rb +51 -0
- data/lib/wallets/callbacks.rb +9 -40
- data/lib/wallets/configuration.rb +19 -11
- data/lib/wallets/engine.rb +5 -9
- data/lib/wallets/models/allocation.rb +48 -16
- data/lib/wallets/models/concerns/embeddable.rb +36 -0
- data/lib/wallets/models/concerns/has_metadata.rb +73 -0
- data/lib/wallets/models/concerns/has_wallets.rb +25 -18
- data/lib/wallets/models/hold.rb +196 -0
- data/lib/wallets/models/transaction.rb +102 -54
- data/lib/wallets/models/transfer.rb +38 -46
- data/lib/wallets/models/wallet.rb +1445 -250
- data/lib/wallets/railtie.rb +6 -3
- data/lib/wallets/version.rb +1 -1
- data/lib/wallets/whole_number.rb +37 -0
- data/lib/wallets.rb +14 -2
- data/sig/wallets.rbs +98 -0
- metadata +17 -16
- data/.gitignore +0 -36
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -37
- data/AGENTS.md +0 -5
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -30
|
@@ -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-
|
|
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,
|
|
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.
|
|
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, :
|
|
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
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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 #
|
|
60
|
-
# ctx.previous_balance #
|
|
61
|
-
# ctx.new_balance #
|
|
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.
|
|
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
|
|
@@ -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
|
data/lib/wallets/callbacks.rb
CHANGED
|
@@ -1,53 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Wallets
|
|
4
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
113
|
+
def on_hold_created(&block)
|
|
114
|
+
@on_hold_created_callback = block
|
|
115
|
+
end
|
|
110
116
|
|
|
111
|
-
def
|
|
112
|
-
|
|
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
|
data/lib/wallets/engine.rb
CHANGED
|
@@ -4,15 +4,11 @@ module Wallets
|
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace Wallets
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|