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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wallets
|
|
4
|
+
# Transactions are the append-only source of truth for wallet balance changes.
|
|
5
|
+
# Positive rows add value, negative rows consume value, and transfers link both
|
|
6
|
+
# sides of an internal movement through `transfer_id`.
|
|
7
|
+
#
|
|
8
|
+
# This class supports embedding: subclasses can override config and table
|
|
9
|
+
# names without affecting the base Wallets::* behavior.
|
|
10
|
+
class Transaction < 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}transactions"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.resolved_config
|
|
19
|
+
value = config_provider
|
|
20
|
+
value.respond_to?(:call) ? value.call : value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
DEFAULT_CATEGORIES = [
|
|
24
|
+
"credit",
|
|
25
|
+
"debit",
|
|
26
|
+
"transfer_in",
|
|
27
|
+
"transfer_out",
|
|
28
|
+
"refund",
|
|
29
|
+
"reward",
|
|
30
|
+
"purchase",
|
|
31
|
+
"top_up",
|
|
32
|
+
"adjustment"
|
|
33
|
+
].freeze
|
|
34
|
+
CATEGORIES = DEFAULT_CATEGORIES
|
|
35
|
+
|
|
36
|
+
def self.categories
|
|
37
|
+
extra_categories =
|
|
38
|
+
if resolved_config.respond_to?(:additional_categories)
|
|
39
|
+
resolved_config.additional_categories
|
|
40
|
+
else
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
(DEFAULT_CATEGORIES + extra_categories).uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
belongs_to :wallet, class_name: "Wallets::Wallet"
|
|
48
|
+
belongs_to :transfer, class_name: "Wallets::Transfer", optional: true
|
|
49
|
+
|
|
50
|
+
has_many :outgoing_allocations,
|
|
51
|
+
class_name: "Wallets::Allocation",
|
|
52
|
+
foreign_key: :transaction_id,
|
|
53
|
+
dependent: :destroy
|
|
54
|
+
|
|
55
|
+
has_many :incoming_allocations,
|
|
56
|
+
class_name: "Wallets::Allocation",
|
|
57
|
+
foreign_key: :source_transaction_id,
|
|
58
|
+
dependent: :destroy
|
|
59
|
+
|
|
60
|
+
validates :amount, presence: true, numericality: { only_integer: true }
|
|
61
|
+
validates :category, presence: true, inclusion: { in: ->(record) { record.class.categories } }
|
|
62
|
+
validate :remaining_amount_cannot_be_negative
|
|
63
|
+
|
|
64
|
+
before_save :sync_metadata_cache
|
|
65
|
+
|
|
66
|
+
scope :credits, -> { where("amount > 0") }
|
|
67
|
+
scope :debits, -> { where("amount < 0") }
|
|
68
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
69
|
+
scope :by_category, ->(category) { where(category: category) }
|
|
70
|
+
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
71
|
+
scope :expired, -> { where("expires_at < ?", Time.current) }
|
|
72
|
+
|
|
73
|
+
def metadata
|
|
74
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def metadata=(hash)
|
|
78
|
+
@indifferent_metadata = nil
|
|
79
|
+
super(hash.respond_to?(:to_h) ? hash.to_h : {})
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reload(*)
|
|
83
|
+
@indifferent_metadata = nil
|
|
84
|
+
super
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def owner
|
|
88
|
+
wallet.owner
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def expired?
|
|
92
|
+
expires_at.present? && expires_at < Time.current
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def credit?
|
|
96
|
+
amount.positive?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def debit?
|
|
100
|
+
amount.negative?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def allocated_amount
|
|
104
|
+
incoming_allocations.sum(:amount)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def spent_amount
|
|
108
|
+
outgoing_allocations.sum(:amount)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def remaining_amount
|
|
112
|
+
return 0 unless credit?
|
|
113
|
+
|
|
114
|
+
amount - allocated_amount
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def unbacked_amount
|
|
118
|
+
return 0 unless debit?
|
|
119
|
+
|
|
120
|
+
amount.abs - spent_amount
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def balance_before
|
|
124
|
+
metadata[:balance_before]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def balance_after
|
|
128
|
+
metadata[:balance_after]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sync_balance_snapshot!(before:, after:)
|
|
132
|
+
update!(metadata: metadata.merge(
|
|
133
|
+
balance_before: before,
|
|
134
|
+
balance_after: after
|
|
135
|
+
))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def sync_metadata_cache
|
|
141
|
+
if @indifferent_metadata
|
|
142
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
143
|
+
elsif read_attribute(:metadata).nil?
|
|
144
|
+
write_attribute(:metadata, {})
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def remaining_amount_cannot_be_negative
|
|
149
|
+
if credit? && remaining_amount.negative?
|
|
150
|
+
errors.add(:base, "Allocated amount exceeds transaction amount")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wallets
|
|
4
|
+
# A transfer records an internal movement of value between two wallets of the
|
|
5
|
+
# same asset. The actual balance impact lives in the linked transactions on
|
|
6
|
+
# each side so the ledger remains append-only.
|
|
7
|
+
#
|
|
8
|
+
# Transfers keep the outbound leg singular and the inbound legs plural so the
|
|
9
|
+
# receiver can preserve the sender's expiration buckets when one transfer
|
|
10
|
+
# consumes multiple source transactions with different expirations.
|
|
11
|
+
class Transfer < ApplicationRecord
|
|
12
|
+
class_attribute :embedded_table_name, default: nil
|
|
13
|
+
class_attribute :config_provider, default: -> { Wallets.configuration }
|
|
14
|
+
class_attribute :transaction_class_name, default: "Wallets::Transaction"
|
|
15
|
+
|
|
16
|
+
SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze
|
|
17
|
+
|
|
18
|
+
def self.table_name
|
|
19
|
+
embedded_table_name || "#{resolved_config.table_prefix}transfers"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.resolved_config
|
|
23
|
+
value = config_provider
|
|
24
|
+
value.respond_to?(:call) ? value.call : value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.transaction_class
|
|
28
|
+
transaction_class_name.constantize
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
belongs_to :from_wallet, class_name: "Wallets::Wallet", inverse_of: :outgoing_transfers
|
|
32
|
+
belongs_to :to_wallet, class_name: "Wallets::Wallet", inverse_of: :incoming_transfers
|
|
33
|
+
|
|
34
|
+
has_many :transactions,
|
|
35
|
+
class_name: "Wallets::Transaction",
|
|
36
|
+
foreign_key: :transfer_id,
|
|
37
|
+
inverse_of: :transfer
|
|
38
|
+
|
|
39
|
+
validates :asset_code, presence: true
|
|
40
|
+
validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
41
|
+
validates :expiration_policy, presence: true, inclusion: { in: SUPPORTED_EXPIRATION_POLICIES }
|
|
42
|
+
validate :wallets_must_differ
|
|
43
|
+
validate :wallet_assets_match_transfer_asset
|
|
44
|
+
|
|
45
|
+
before_validation :normalize_asset_code!
|
|
46
|
+
before_validation :normalize_expiration_policy!
|
|
47
|
+
before_save :sync_metadata_cache
|
|
48
|
+
|
|
49
|
+
def metadata
|
|
50
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def metadata=(hash)
|
|
54
|
+
@indifferent_metadata = nil
|
|
55
|
+
super(hash.respond_to?(:to_h) ? hash.to_h : {})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reload(*)
|
|
59
|
+
@indifferent_metadata = nil
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def outbound_transactions
|
|
64
|
+
transfer_transactions_for(wallet_id: from_wallet_id).where("amount < 0")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def outbound_transaction
|
|
68
|
+
outbound_transactions.order(:id).first
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inbound_transactions
|
|
72
|
+
transfer_transactions_for(wallet_id: to_wallet_id).where("amount > 0")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inbound_transaction
|
|
76
|
+
records = inbound_transactions.order(:id).limit(2).to_a
|
|
77
|
+
records.one? ? records.first : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def transaction_class
|
|
83
|
+
self.class.transaction_class
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_asset_code!
|
|
87
|
+
self.asset_code = asset_code.to_s.strip.downcase.presence
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_expiration_policy!
|
|
91
|
+
self.expiration_policy = expiration_policy.to_s.strip.downcase.presence
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def wallets_must_differ
|
|
95
|
+
return if from_wallet.blank? || to_wallet.blank?
|
|
96
|
+
return if from_wallet_id != to_wallet_id
|
|
97
|
+
|
|
98
|
+
errors.add(:to_wallet, "must be different from from_wallet")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wallet_assets_match_transfer_asset
|
|
102
|
+
return if from_wallet.blank? || to_wallet.blank? || asset_code.blank?
|
|
103
|
+
return if from_wallet.asset_code == asset_code && to_wallet.asset_code == asset_code
|
|
104
|
+
|
|
105
|
+
errors.add(:asset_code, "must match both wallets")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def sync_metadata_cache
|
|
109
|
+
if @indifferent_metadata
|
|
110
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
111
|
+
elsif read_attribute(:metadata).nil?
|
|
112
|
+
write_attribute(:metadata, {})
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def transfer_transactions_for(wallet_id:)
|
|
117
|
+
return transaction_class.none unless persisted? && wallet_id.present?
|
|
118
|
+
|
|
119
|
+
transaction_class.where(transfer_id: id, wallet_id: wallet_id)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|