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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +683 -27
- 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 +51 -18
- 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 +1444 -298
- 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 -17
- 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
- data/context7.json +0 -4
|
@@ -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
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|