wallets 0.2.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.
@@ -2,30 +2,35 @@
2
2
 
3
3
  module Wallets
4
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
5
+ # consumed from. This makes expiration-aware spending and balances
6
6
  # possible without mutating historical transactions.
7
7
  #
8
8
  # This class supports embedding: subclasses can override config and table
9
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 }
10
+ class AllocationBase < ApplicationRecord
11
+ # Abstract on purpose: embedders (like usage_credits) subclass THIS
12
+ # class, not the concrete Allocation below. ActiveRecord builds a
13
+ # subclass's attribute methods on its parent's, so a concrete parent
14
+ # forces a schema load of the wallets_* tables — which do not exist in
15
+ # apps that only run an embedded ledger (fresh usage_credits installs).
16
+ # An abstract parent makes each embedded subclass its own base_class
17
+ # and keeps the base tables out of the picture entirely.
18
+ self.abstract_class = true
19
+ include Wallets::Embeddable
13
20
 
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
21
+ self.table_suffix = "allocations"
22
22
 
23
- belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id"
24
- belongs_to :source_transaction, class_name: "Wallets::Transaction"
23
+ # Explicit `optional: false` because the gem's models load before Rails
24
+ # applies `belongs_to_required_by_default`.
25
+ belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id", optional: false
26
+ belongs_to :source_transaction, class_name: "Wallets::Transaction", optional: false
25
27
 
26
- validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
28
+ validates :amount, presence: true, numericality: {only_integer: true, greater_than: 0}
27
29
  validate :source_transaction_has_matching_asset
30
+ validate :spend_transaction_must_be_a_debit
31
+ validate :source_transaction_must_be_a_credit
28
32
  validate :allocation_does_not_exceed_remaining_amount
33
+ validate :allocation_does_not_exceed_unbacked_amount
29
34
 
30
35
  private
31
36
 
@@ -39,9 +44,36 @@ module Wallets
39
44
  def allocation_does_not_exceed_remaining_amount
40
45
  return if amount.blank? || source_transaction.blank?
41
46
 
42
- if source_transaction.remaining_amount < amount
47
+ remaining_amount = source_transaction.amount - source_transaction.incoming_allocations.where.not(id: id).sum(:amount)
48
+ if remaining_amount < amount
43
49
  errors.add(:amount, "exceeds the remaining amount of the source transaction")
44
50
  end
45
51
  end
52
+
53
+ def spend_transaction_must_be_a_debit
54
+ return if spend_transaction.blank? || spend_transaction.debit?
55
+
56
+ errors.add(:spend_transaction, "must be a debit transaction")
57
+ end
58
+
59
+ def source_transaction_must_be_a_credit
60
+ return if source_transaction.blank? || source_transaction.credit?
61
+
62
+ errors.add(:source_transaction, "must be a credit transaction")
63
+ end
64
+
65
+ def allocation_does_not_exceed_unbacked_amount
66
+ return if amount.blank? || spend_transaction.blank? || !spend_transaction.debit?
67
+
68
+ unbacked_amount = spend_transaction.amount.abs - spend_transaction.outgoing_allocations.where.not(id: id).sum(:amount)
69
+ if unbacked_amount < amount
70
+ errors.add(:amount, "exceeds the unbacked amount of the spend transaction")
71
+ end
72
+ end
73
+ end
74
+
75
+ # The concrete standalone ledger model (table: wallets_allocations via the
76
+ # default config). Embedders subclass AllocationBase instead.
77
+ class Allocation < AllocationBase
46
78
  end
47
79
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Lets other gems (like usage_credits) subclass the ledger models with their
5
+ # own tables, configuration, and callbacks without touching global Wallets
6
+ # state in the same application.
7
+ #
8
+ # The embedding contract on a subclass:
9
+ #
10
+ # self.embedded_table_name = "usage_credits_wallets" # exact table name
11
+ # self.config_provider = -> { UsageCredits.configuration } # custom config
12
+ #
13
+ # When `embedded_table_name` is not set, the table name is derived from the
14
+ # provided config's `table_prefix` plus the model's `table_suffix`
15
+ # ("wallets", "transactions", "allocations", "transfers", or "holds").
16
+ module Embeddable
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ class_attribute :embedded_table_name, default: nil
21
+ class_attribute :config_provider, default: -> { Wallets.configuration }
22
+ class_attribute :table_suffix, default: nil, instance_accessor: false
23
+ end
24
+
25
+ class_methods do
26
+ def table_name
27
+ embedded_table_name || "#{resolved_config.table_prefix}#{table_suffix}"
28
+ end
29
+
30
+ def resolved_config
31
+ value = config_provider
32
+ value.respond_to?(:call) ? value.call : value
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # Shared metadata behavior for ledger records: metadata always reads as a
5
+ # hash with indifferent access, accepts anything hash-like on assignment,
6
+ # and in-place mutations survive `save`.
7
+ #
8
+ # MySQL cannot give JSON columns a default, so a NULL column is treated the
9
+ # same as an empty hash everywhere.
10
+ #
11
+ # Including the concern gives the `metadata` column this behavior. A model
12
+ # with another JSON column of the same kind names it:
13
+ #
14
+ # has_metadata_column :release_metadata
15
+ module HasMetadata
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ class_attribute :metadata_column_names, default: [].freeze, instance_writer: false, instance_predicate: false
20
+
21
+ before_save :sync_metadata_cache
22
+
23
+ has_metadata_column :metadata
24
+ end
25
+
26
+ class_methods do
27
+ def has_metadata_column(name)
28
+ name = name.to_sym
29
+ cache = :"@indifferent_#{name}"
30
+ self.metadata_column_names = (metadata_column_names + [name]).freeze
31
+
32
+ # A module, not methods on the class itself, so the including class
33
+ # can still override an accessor and call `super`.
34
+ #
35
+ # read_attribute/write_attribute, NOT `super`: this concern is included
36
+ # on the abstract *Base classes, while ActiveRecord generates the plain
37
+ # attribute readers/writers lazily on each CONCRETE descendant — below
38
+ # this module in the ancestor chain — so a `super` here can find no
39
+ # generated method to call.
40
+ include(Module.new do
41
+ define_method(name) do
42
+ instance_variable_get(cache) ||
43
+ instance_variable_set(cache, ActiveSupport::HashWithIndifferentAccess.new(read_attribute(name) || {}))
44
+ end
45
+
46
+ define_method(:"#{name}=") do |hash|
47
+ instance_variable_set(cache, nil)
48
+ write_attribute(name, hash.respond_to?(:to_h) ? hash.to_h : {})
49
+ end
50
+ end)
51
+ end
52
+ end
53
+
54
+ def reload(*)
55
+ metadata_column_names.each { |name| instance_variable_set(:"@indifferent_#{name}", nil) }
56
+ super
57
+ end
58
+
59
+ private
60
+
61
+ def sync_metadata_cache
62
+ metadata_column_names.each do |name|
63
+ cached = instance_variable_get(:"@indifferent_#{name}")
64
+
65
+ if cached
66
+ write_attribute(name, cached.to_h)
67
+ elsif read_attribute(name).nil?
68
+ write_attribute(name, {})
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -11,27 +11,38 @@ module Wallets
11
11
  def has_wallets(**options)
12
12
  include Wallets::HasWallets unless included_modules.include?(Wallets::HasWallets)
13
13
 
14
- @wallet_options = {
15
- default_asset: Wallets.configuration.default_asset,
16
- auto_create: true,
17
- initial_balance: 0
18
- }.merge(options)
14
+ @wallet_options = options
19
15
  end
20
16
 
17
+ # Resolved lazily on every call so subclasses inherit their parent's
18
+ # `has_wallets` declaration and so `Wallets.configuration.default_asset`
19
+ # is honored no matter when the host app's initializer ran.
21
20
  def wallet_options
22
- @wallet_options ||= {
21
+ {
23
22
  default_asset: Wallets.configuration.default_asset,
24
23
  auto_create: true,
25
24
  initial_balance: 0
26
- }
25
+ }.merge(declared_wallet_options)
26
+ end
27
+
28
+ private
29
+
30
+ def declared_wallet_options
31
+ if defined?(@wallet_options) && @wallet_options
32
+ @wallet_options
33
+ elsif superclass.respond_to?(:declared_wallet_options, true)
34
+ superclass.send(:declared_wallet_options)
35
+ else
36
+ {}
37
+ end
27
38
  end
28
39
  end
29
40
 
30
41
  included do
31
42
  has_many :wallets,
32
- class_name: "Wallets::Wallet",
33
- as: :owner,
34
- dependent: :destroy
43
+ class_name: "Wallets::Wallet",
44
+ as: :owner,
45
+ dependent: :destroy
35
46
 
36
47
  after_create :create_main_wallet, if: :should_auto_create_wallet?
37
48
  end
@@ -45,7 +56,7 @@ module Wallets
45
56
  end
46
57
 
47
58
  def wallet?(asset_code = nil)
48
- find_wallet(asset_code || wallet_options[:default_asset]).present?
59
+ find_wallet(asset_code).present?
49
60
  end
50
61
 
51
62
  def main_wallet
@@ -53,7 +64,7 @@ module Wallets
53
64
  end
54
65
 
55
66
  def find_wallet(asset_code = nil)
56
- normalized_asset_code = normalize_asset_code(asset_code || wallet_options[:default_asset])
67
+ normalized_asset_code = Wallets.normalize_asset_code(asset_code || wallet_options[:default_asset])
57
68
  wallets.find_by(asset_code: normalized_asset_code)
58
69
  end
59
70
 
@@ -67,7 +78,7 @@ module Wallets
67
78
  existing_wallet = find_wallet(asset_code)
68
79
  return existing_wallet if existing_wallet.present?
69
80
  return unless should_auto_create_wallet?
70
- raise "Cannot create wallet for unsaved owner" unless persisted?
81
+ raise Wallets::Error, "Cannot create wallet for unsaved owner" unless persisted?
71
82
 
72
83
  Wallet.create_for_owner!(
73
84
  owner: self,
@@ -80,12 +91,8 @@ module Wallets
80
91
  main_wallet
81
92
  end
82
93
 
83
- def normalize_asset_code(value)
84
- value.to_s.strip.downcase
85
- end
86
-
87
94
  def initial_balance_for(asset_code)
88
- return 0 unless normalize_asset_code(asset_code) == normalize_asset_code(wallet_options[:default_asset])
95
+ return 0 unless Wallets.normalize_asset_code(asset_code) == Wallets.normalize_asset_code(wallet_options[:default_asset])
89
96
 
90
97
  wallet_options[:initial_balance] || 0
91
98
  end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallets
4
+ # A hold keeps value in the ledger but out of the available balance, like a
5
+ # deposit that has not cleared or a card charge that is still pending.
6
+ # One table, three shapes:
7
+ #
8
+ # - a credit hold (`transaction_id` set, no amount) holds that credit's whole
9
+ # remaining value. Several holds on one credit are independent reasons;
10
+ # the credit is held while any of them is active and is counted once.
11
+ # - a wallet hold (no `transaction_id`, no amount) holds every credit in the
12
+ # wallet, including credits received later.
13
+ # - a reservation (no `transaction_id`, an amount) sets that much of the
14
+ # wallet's available value aside. Reservations add up, like pending card
15
+ # charges.
16
+ #
17
+ # A hold is active until it is released (`released_at`) or its deadline
18
+ # passes (`releases_at`). Deadlines are computed, never swept, exactly like
19
+ # `expires_at`: at the deadline itself the hold is already inactive.
20
+ #
21
+ # A row validates what it can see on itself, plus the one link it must
22
+ # never carry wrongly: a capture must name a debit of its own wallet no
23
+ # larger than the reservation. Every other rule that spans records (the
24
+ # credit belongs to this wallet and still has value, the wallet can fund a
25
+ # reservation) is enforced by the wallet's commands under the wallet lock,
26
+ # where they cannot race.
27
+ #
28
+ # This class supports embedding: subclasses can override config and table
29
+ # names without affecting the base Wallets::* behavior.
30
+ class HoldBase < ApplicationRecord
31
+ # Abstract on purpose: embedders (like usage_credits) subclass THIS
32
+ # class, not the concrete Hold below. ActiveRecord builds a subclass's
33
+ # attribute methods on its parent's, so a concrete parent forces a schema
34
+ # load of the wallets_* tables — which do not exist in apps that only run
35
+ # an embedded ledger. An abstract parent makes each embedded subclass its
36
+ # own base_class and keeps the base tables out of the picture entirely.
37
+ self.abstract_class = true
38
+ include Wallets::Embeddable
39
+ include Wallets::HasMetadata
40
+
41
+ # Release facts live next to the creation facts, never over them.
42
+ has_metadata_column :release_metadata
43
+
44
+ self.table_suffix = "holds"
45
+
46
+ # Explicit `optional:` flags because the gem's models load before Rails
47
+ # applies `belongs_to_required_by_default`.
48
+ belongs_to :wallet, class_name: "Wallets::Wallet", inverse_of: :holds, optional: false
49
+ # Named `credit`, not `transaction`: Active Record defines `#transaction`
50
+ # on every record and refuses an association that would shadow it. The
51
+ # column keeps the ledger's `transaction_id` name, as allocations do.
52
+ belongs_to :credit, class_name: "Wallets::Transaction", foreign_key: :transaction_id, inverse_of: :holds, optional: true
53
+ belongs_to :capture_transaction, class_name: "Wallets::Transaction", inverse_of: :captured_reservation, optional: true
54
+
55
+ validates :category, presence: true
56
+ validate :amount_must_be_nil_or_a_positive_whole_number
57
+ # A credit hold always holds the credit's whole remaining value: an amount
58
+ # on it would raise the unanswerable "the same 40 or another 40?" question.
59
+ validates :amount, absence: {message: "must be blank on a credit hold"}, if: :credit_scoped?
60
+
61
+ # Only an ended reservation can name the debit it was captured into, so a
62
+ # coverage hold can never carry a capture link.
63
+ with_options if: :captured? do
64
+ validates :transaction_id, absence: {message: "must be blank on a captured reservation"}
65
+ validates :amount, presence: {message: "can't be blank on a captured reservation"}
66
+ validates :released_at, presence: {message: "can't be blank on a captured reservation"}
67
+ validate :capture_must_be_a_debit_of_this_reservation
68
+ end
69
+
70
+ before_validation :normalize_category!
71
+
72
+ # Qualify predicates through Arel so these scopes stay composable when a
73
+ # joined table also has `amount` or `transaction_id` columns.
74
+ #
75
+ # A hold whose deadline is exactly "now" is already inactive: the same
76
+ # boundary `not_expired` and `expired` draw for transactions.
77
+ scope :active, ->(at: Time.current) {
78
+ where(arel_table[:released_at].eq(nil))
79
+ .where(arel_table[:releases_at].eq(nil).or(arel_table[:releases_at].gt(at)))
80
+ }
81
+ scope :released, -> { where(arel_table[:released_at].not_eq(nil)) }
82
+ scope :coverage, -> { where(arel_table[:amount].eq(nil)) }
83
+ scope :reservations, -> { where(arel_table[:amount].not_eq(nil)) }
84
+ scope :wallet_scoped, -> { where(arel_table[:transaction_id].eq(nil)) }
85
+ scope :credit_scoped, -> { where(arel_table[:transaction_id].not_eq(nil)) }
86
+
87
+ def active?(at: Time.current)
88
+ released_at.nil? && (releases_at.nil? || releases_at > at)
89
+ end
90
+
91
+ # Ended explicitly: released by hand or by a capture.
92
+ def released?
93
+ released_at.present?
94
+ end
95
+
96
+ # Ended by its deadline alone: inactive, yet never released.
97
+ def lapsed?(at: Time.current)
98
+ !released? && releases_at.present? && releases_at <= at
99
+ end
100
+
101
+ def captured?
102
+ capture_transaction_id.present?
103
+ end
104
+
105
+ def reservation?
106
+ !amount.nil?
107
+ end
108
+
109
+ def coverage?
110
+ amount.nil?
111
+ end
112
+
113
+ def wallet_scoped?
114
+ transaction_id.nil?
115
+ end
116
+
117
+ def credit_scoped?
118
+ !wallet_scoped?
119
+ end
120
+
121
+ # Ends this hold now: its value is available again, unless another reason
122
+ # still holds it. Idempotent: a hold that has already ended (released,
123
+ # captured, or past its deadline) is returned unchanged and reports
124
+ # nothing. Why it ended is recorded next to why it began, never over it.
125
+ #
126
+ # hold.release!(reason: "cleared_by_operator", metadata: {actor_id: operator.id})
127
+ def release!(reason: nil, metadata: {})
128
+ raise InvalidHold, "Only a saved hold can be released" unless persisted?
129
+
130
+ # The wallet runs the release: it takes its own lock before reading
131
+ # this hold, the same order as every other command.
132
+ wallet.send(:release_hold!, self, reason: reason, metadata: metadata)
133
+ end
134
+
135
+ # Charges this reservation: posts a debit for the reserved amount, or
136
+ # less, and ends the reservation in the same step, the way a merchant
137
+ # captures a pending card charge. A smaller capture releases the rest.
138
+ # Returns the debit, which `capture_transaction` reaches from then on.
139
+ #
140
+ # debit = reservation.capture!(2_750, category: :booking_charge, metadata: {booking_id: 9})
141
+ # reservation.capture_transaction # => debit
142
+ #
143
+ # Only an active reservation can be captured, once: a released, lapsed
144
+ # or already captured one raises Wallets::InvalidHold and creates
145
+ # nothing, so a retried request never charges twice. Capture is still
146
+ # funded at the moment it happens: value a hold covers is not spent, and
147
+ # unless overdrafts are on a capture the wallet cannot fund raises
148
+ # Wallets::InsufficientBalance and leaves the reservation as it was.
149
+ def capture!(amount = nil, category:, metadata: {})
150
+ raise InvalidHold, "Only a saved reservation can be captured" unless persisted?
151
+
152
+ # The wallet runs the capture: it takes its own lock before reading
153
+ # this reservation, the same order as every other command.
154
+ wallet.send(:capture_reservation!, self, amount, category: category, metadata: metadata)
155
+ end
156
+
157
+ private
158
+
159
+ # What the link names, not just that it exists: one of this wallet's
160
+ # debits, no larger than what was reserved. A dangling id is left to the
161
+ # foreign key.
162
+ def capture_must_be_a_debit_of_this_reservation
163
+ debit = capture_transaction
164
+ return if debit.nil?
165
+
166
+ errors.add(:capture_transaction, "must be a debit") unless debit.debit?
167
+ errors.add(:capture_transaction, "must belong to the reservation's wallet") unless debit.wallet_id == wallet_id
168
+ if debit.debit? && amount.is_a?(Integer) && debit.amount.abs > amount
169
+ errors.add(:capture_transaction, "cannot exceed the reserved amount")
170
+ end
171
+ end
172
+
173
+ def normalize_category!
174
+ self.category = category.to_s.strip.presence
175
+ end
176
+
177
+ # Checked on the raw assigned value: the integer column casts "ten" and
178
+ # Float::INFINITY to nil, which would silently turn a bad reservation
179
+ # into a coverage hold.
180
+ def amount_must_be_nil_or_a_positive_whole_number
181
+ raw_amount = amount_before_type_cast
182
+ return if raw_amount.nil?
183
+
184
+ unless Wallets::WholeNumber.parse(raw_amount, name: "Amount", allow_string: true).positive?
185
+ errors.add(:amount, :greater_than, count: 0)
186
+ end
187
+ rescue ArgumentError
188
+ errors.add(:amount, :not_an_integer)
189
+ end
190
+ end
191
+
192
+ # The concrete standalone hold model (table: wallets_holds via the default
193
+ # config). Embedders subclass HoldBase instead.
194
+ class Hold < HoldBase
195
+ end
196
+ end