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.
- checksums.yaml +7 -0
- data/.gitignore +36 -0
- data/.rubocop.yml +8 -0
- data/.simplecov +37 -0
- data/AGENTS.md +5 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +740 -0
- data/Rakefile +30 -0
- data/lib/generators/wallets/install_generator.rb +43 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +113 -0
- data/lib/generators/wallets/templates/initializer.rb +87 -0
- data/lib/wallets/callback_context.rb +28 -0
- data/lib/wallets/callbacks.rb +53 -0
- data/lib/wallets/configuration.rb +119 -0
- data/lib/wallets/engine.rb +23 -0
- data/lib/wallets/models/allocation.rb +47 -0
- data/lib/wallets/models/concerns/has_wallets.rb +93 -0
- data/lib/wallets/models/transaction.rb +154 -0
- data/lib/wallets/models/transfer.rb +122 -0
- data/lib/wallets/models/wallet.rb +654 -0
- data/lib/wallets/railtie.rb +7 -0
- data/lib/wallets/version.rb +5 -0
- data/lib/wallets.rb +45 -0
- data/sig/wallets.rbs +3 -0
- data/wallets.webp +0 -0
- metadata +96 -0
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
|