rbbb 0.1.0.pre.3
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/LICENSE +21 -0
- data/README.md +159 -0
- data/lib/rbbb/configuration.rb +93 -0
- data/lib/rbbb/decision.rb +56 -0
- data/lib/rbbb/engine.rb +870 -0
- data/lib/rbbb/event.rb +44 -0
- data/lib/rbbb/increment_schedule.rb +78 -0
- data/lib/rbbb/money.rb +70 -0
- data/lib/rbbb/state.rb +462 -0
- data/lib/rbbb/timestamp.rb +55 -0
- data/lib/rbbb/version.rb +7 -0
- data/lib/rbbb.rb +27 -0
- metadata +97 -0
data/lib/rbbb/event.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBBB
|
|
4
|
+
# Immutable fact emitted by an accepted engine command.
|
|
5
|
+
class Event
|
|
6
|
+
VISIBILITIES = %i[public privileged].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :type, :visibility, :data
|
|
9
|
+
|
|
10
|
+
def initialize(type:, visibility:, data: {})
|
|
11
|
+
raise ArgumentError, "unknown event visibility" unless VISIBILITIES.include?(visibility)
|
|
12
|
+
|
|
13
|
+
@type = type.to_s.freeze
|
|
14
|
+
@visibility = visibility
|
|
15
|
+
@data = deep_freeze(data.transform_keys(&:to_s))
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def public?
|
|
20
|
+
visibility == :public
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def privileged?
|
|
24
|
+
visibility == :privileged
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{"type" => type}.merge(data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def deep_freeze(value)
|
|
34
|
+
case value
|
|
35
|
+
when Hash
|
|
36
|
+
value.transform_values { |nested| deep_freeze(nested) }.freeze
|
|
37
|
+
when Array
|
|
38
|
+
value.map { |nested| deep_freeze(nested) }.freeze
|
|
39
|
+
else
|
|
40
|
+
value.freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBBB
|
|
4
|
+
# Lower-bound tiers used to select the increment for an exact amount.
|
|
5
|
+
class IncrementSchedule
|
|
6
|
+
Tier = Struct.new(:from_minor_units, :amount_minor_units, keyword_init: true) do
|
|
7
|
+
def to_h
|
|
8
|
+
{
|
|
9
|
+
"from_minor_units" => from_minor_units,
|
|
10
|
+
"amount_minor_units" => amount_minor_units
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :tiers
|
|
16
|
+
|
|
17
|
+
def initialize(tiers)
|
|
18
|
+
@tiers = Array(tiers).map do |tier|
|
|
19
|
+
unless tier.respond_to?(:transform_keys)
|
|
20
|
+
raise InvalidConfiguration, "increment tier must be an object"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
values = tier.transform_keys(&:to_s)
|
|
24
|
+
Tier.new(
|
|
25
|
+
from_minor_units: values.fetch("from_minor_units"),
|
|
26
|
+
amount_minor_units: values.fetch("amount_minor_units")
|
|
27
|
+
).freeze
|
|
28
|
+
end
|
|
29
|
+
validate_tier_types!
|
|
30
|
+
@tiers = @tiers.sort_by(&:from_minor_units)
|
|
31
|
+
|
|
32
|
+
validate!
|
|
33
|
+
@tiers.freeze
|
|
34
|
+
freeze
|
|
35
|
+
rescue KeyError => e
|
|
36
|
+
raise InvalidConfiguration, "increment tier is missing #{e.key}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def increment_for(minor_units)
|
|
40
|
+
unless minor_units.is_a?(Integer) && minor_units >= 0
|
|
41
|
+
raise InvalidConfiguration, "increment lookup amount must be a non-negative integer"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
tiers.reverse_each.find { |tier| tier.from_minor_units <= minor_units }.amount_minor_units
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_a
|
|
48
|
+
tiers.map(&:to_h)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_tier_types!
|
|
54
|
+
tiers.each do |tier|
|
|
55
|
+
unless Money.amount?(tier.from_minor_units)
|
|
56
|
+
raise InvalidConfiguration,
|
|
57
|
+
"increment lower bounds must be non-negative integers no greater than #{Money::MAX_MINOR_UNITS}"
|
|
58
|
+
end
|
|
59
|
+
unless Money.amount?(tier.amount_minor_units) && tier.amount_minor_units.positive?
|
|
60
|
+
raise InvalidConfiguration,
|
|
61
|
+
"increments must be positive integers no greater than #{Money::MAX_MINOR_UNITS}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate!
|
|
67
|
+
raise InvalidConfiguration, "increment schedule must contain at least one tier" if tiers.empty?
|
|
68
|
+
unless tiers.first.from_minor_units == 0
|
|
69
|
+
raise InvalidConfiguration, "increment schedule must begin at zero"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
duplicate = tiers.each_cons(2).find do |left, right|
|
|
73
|
+
left.from_minor_units == right.from_minor_units
|
|
74
|
+
end
|
|
75
|
+
raise InvalidConfiguration, "increment lower bounds must be unique" if duplicate
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/rbbb/money.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBBB
|
|
4
|
+
# Exact monetary value represented without floating point.
|
|
5
|
+
class Money
|
|
6
|
+
include Comparable
|
|
7
|
+
|
|
8
|
+
# Largest amount any RFC 0001 field may carry. Every conforming
|
|
9
|
+
# implementation must produce identical results, so amounts share the
|
|
10
|
+
# interoperable integer bound in RBBB::MAX_SAFE_INTEGER.
|
|
11
|
+
MAX_MINOR_UNITS = MAX_SAFE_INTEGER
|
|
12
|
+
|
|
13
|
+
attr_reader :currency, :minor_units
|
|
14
|
+
|
|
15
|
+
# True when value is a non-negative integer inside the interoperable range.
|
|
16
|
+
def self.amount?(value)
|
|
17
|
+
value.is_a?(Integer) && value >= 0 && value <= MAX_MINOR_UNITS
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(currency:, minor_units:)
|
|
21
|
+
unless currency.is_a?(String) && currency.match?(/\A[A-Z]{3}\z/)
|
|
22
|
+
raise InvalidMoney, "currency must be a three-letter uppercase code"
|
|
23
|
+
end
|
|
24
|
+
raise InvalidMoney, "minor_units must be an integer" unless minor_units.is_a?(Integer)
|
|
25
|
+
if minor_units.abs > MAX_MINOR_UNITS
|
|
26
|
+
raise InvalidMoney, "minor_units must not exceed #{MAX_MINOR_UNITS}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@currency = currency.freeze
|
|
30
|
+
@minor_units = minor_units
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def <=>(other)
|
|
35
|
+
ensure_same_currency!(other)
|
|
36
|
+
minor_units <=> other.minor_units
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def +(other)
|
|
40
|
+
ensure_same_currency!(other)
|
|
41
|
+
self.class.new(currency: currency, minor_units: minor_units + other.minor_units)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def -(other)
|
|
45
|
+
ensure_same_currency!(other)
|
|
46
|
+
self.class.new(currency: currency, minor_units: minor_units - other.minor_units)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ==(other)
|
|
50
|
+
other.is_a?(Money) && currency == other.currency && minor_units == other.minor_units
|
|
51
|
+
end
|
|
52
|
+
alias eql? ==
|
|
53
|
+
|
|
54
|
+
def hash
|
|
55
|
+
[currency, minor_units].hash
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
{"currency" => currency, "minor_units" => minor_units}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def ensure_same_currency!(other)
|
|
65
|
+
unless other.is_a?(Money) && other.currency == currency
|
|
66
|
+
raise InvalidMoney, "money operations require matching currencies"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/rbbb/state.rb
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBBB
|
|
4
|
+
# Replayable aggregate state for one independent bidding unit.
|
|
5
|
+
class State
|
|
6
|
+
Position = Struct.new(
|
|
7
|
+
:maximum_minor_units,
|
|
8
|
+
:priority,
|
|
9
|
+
:executed_minor_units,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
) do
|
|
12
|
+
def to_h
|
|
13
|
+
{
|
|
14
|
+
"maximum_minor_units" => maximum_minor_units,
|
|
15
|
+
"priority" => priority,
|
|
16
|
+
"executed_minor_units" => executed_minor_units
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :version, :positions, :leader_id, :standing_minor_units,
|
|
22
|
+
:next_required_minor_units, :reserve_status, :opens_at, :closes_at,
|
|
23
|
+
:last_effective_at, :reserve_minor_units, :authorization_history,
|
|
24
|
+
:reserve_history, :voided_bid_ids, :status, :result, :winner_id,
|
|
25
|
+
:winning_minor_units
|
|
26
|
+
|
|
27
|
+
# Aggregate snapshot keys owned by the bidding unit. A host adds
|
|
28
|
+
# auction_id, bidding_unit_id, and currency to complete
|
|
29
|
+
# specification/state/aggregate.schema.json.
|
|
30
|
+
SNAPSHOT_KEYS = %w[
|
|
31
|
+
version status opens_at closes_at last_effective_at reserve_minor_units
|
|
32
|
+
reserve_status leader_id standing_minor_units next_required_minor_units
|
|
33
|
+
positions authorization_history reserve_history voided_bid_ids result
|
|
34
|
+
winner_id winning_minor_units
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# Public projection keys owned by the bidding unit, per
|
|
38
|
+
# specification/state/bidding-unit.schema.json. Never add an identity,
|
|
39
|
+
# a maximum, a reserve amount, or audit history here.
|
|
40
|
+
PUBLIC_VIEW_KEYS = %w[
|
|
41
|
+
version status opens_at closes_at standing_minor_units
|
|
42
|
+
next_required_minor_units reserve_status result winning_minor_units
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Keys every privileged state-transition event carries. Closing adds
|
|
46
|
+
# status, result, winner_id, and winning_minor_units.
|
|
47
|
+
TRANSITION_KEYS = %w[
|
|
48
|
+
aggregate_version closes_at reserve_minor_units reserve_status leader_id
|
|
49
|
+
standing_minor_units next_required_minor_units positions
|
|
50
|
+
authorization_history reserve_history voided_bid_ids
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
def self.empty(configuration)
|
|
54
|
+
new(
|
|
55
|
+
version: 0,
|
|
56
|
+
positions: {},
|
|
57
|
+
leader_id: nil,
|
|
58
|
+
standing_minor_units: nil,
|
|
59
|
+
next_required_minor_units: configuration.opening_minor_units,
|
|
60
|
+
reserve_status: configuration.reserve_minor_units.nil? ? nil : "reserve_not_met",
|
|
61
|
+
reserve_minor_units: configuration.reserve_minor_units,
|
|
62
|
+
opens_at: configuration.opens_at,
|
|
63
|
+
closes_at: configuration.closes_at,
|
|
64
|
+
last_effective_at: nil,
|
|
65
|
+
authorization_history: [],
|
|
66
|
+
reserve_history: [],
|
|
67
|
+
voided_bid_ids: [],
|
|
68
|
+
status: "open"
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Rebuild a validated state from a `State#to_h` snapshot. Identity keys
|
|
73
|
+
# added by the host are ignored; every snapshot key must be present.
|
|
74
|
+
def self.from_h(snapshot)
|
|
75
|
+
raise InvalidState, "snapshot must be an object" unless snapshot.respond_to?(:transform_keys)
|
|
76
|
+
|
|
77
|
+
values = snapshot.transform_keys(&:to_s)
|
|
78
|
+
missing = SNAPSHOT_KEYS.reject { |key| values.key?(key) }
|
|
79
|
+
raise InvalidState, "snapshot is missing #{missing.join(', ')}" if missing.any?
|
|
80
|
+
|
|
81
|
+
new(
|
|
82
|
+
version: values.fetch("version"),
|
|
83
|
+
positions: values.fetch("positions"),
|
|
84
|
+
leader_id: values.fetch("leader_id"),
|
|
85
|
+
standing_minor_units: values.fetch("standing_minor_units"),
|
|
86
|
+
next_required_minor_units: values.fetch("next_required_minor_units"),
|
|
87
|
+
reserve_status: values.fetch("reserve_status"),
|
|
88
|
+
reserve_minor_units: values.fetch("reserve_minor_units"),
|
|
89
|
+
opens_at: values.fetch("opens_at"),
|
|
90
|
+
closes_at: values.fetch("closes_at"),
|
|
91
|
+
last_effective_at: values.fetch("last_effective_at"),
|
|
92
|
+
authorization_history: values.fetch("authorization_history"),
|
|
93
|
+
reserve_history: values.fetch("reserve_history"),
|
|
94
|
+
voided_bid_ids: values.fetch("voided_bid_ids"),
|
|
95
|
+
status: values.fetch("status"),
|
|
96
|
+
result: values.fetch("result"),
|
|
97
|
+
winner_id: values.fetch("winner_id"),
|
|
98
|
+
winning_minor_units: values.fetch("winning_minor_units")
|
|
99
|
+
)
|
|
100
|
+
rescue NoMethodError, TypeError
|
|
101
|
+
raise InvalidState, "snapshot is malformed"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Rebuild a validated state from one privileged state-transition event
|
|
105
|
+
# (or its data hash). Every transition carries the full aggregate
|
|
106
|
+
# snapshot, so a host can checkpoint from the latest transition instead
|
|
107
|
+
# of replaying the stream from version 0. `opens_at` is fixed by
|
|
108
|
+
# configuration and is not repeated in events.
|
|
109
|
+
def self.from_transition(configuration, event_or_data)
|
|
110
|
+
data = event_or_data.respond_to?(:data) ? event_or_data.data : event_or_data
|
|
111
|
+
raise InvalidState, "transition data must be an object" unless data.respond_to?(:transform_keys)
|
|
112
|
+
|
|
113
|
+
values = data.transform_keys(&:to_s)
|
|
114
|
+
missing = TRANSITION_KEYS.reject { |key| values.key?(key) }
|
|
115
|
+
raise InvalidState, "transition is missing #{missing.join(', ')}" if missing.any?
|
|
116
|
+
|
|
117
|
+
from_h(
|
|
118
|
+
"version" => values.fetch("aggregate_version"),
|
|
119
|
+
"status" => values.fetch("status", "open"),
|
|
120
|
+
"opens_at" => configuration.opens_at,
|
|
121
|
+
"closes_at" => values["closes_at"],
|
|
122
|
+
"last_effective_at" => values["effective_at"],
|
|
123
|
+
"reserve_minor_units" => values["reserve_minor_units"],
|
|
124
|
+
"reserve_status" => values["reserve_status"],
|
|
125
|
+
"leader_id" => values["leader_id"],
|
|
126
|
+
"standing_minor_units" => values["standing_minor_units"],
|
|
127
|
+
"next_required_minor_units" => values["next_required_minor_units"],
|
|
128
|
+
"positions" => values["positions"],
|
|
129
|
+
"authorization_history" => values["authorization_history"],
|
|
130
|
+
"reserve_history" => values["reserve_history"],
|
|
131
|
+
"voided_bid_ids" => values["voided_bid_ids"],
|
|
132
|
+
"result" => values["result"],
|
|
133
|
+
"winner_id" => values["winner_id"],
|
|
134
|
+
"winning_minor_units" => values["winning_minor_units"]
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def initialize(version:, positions:, leader_id:, standing_minor_units:,
|
|
139
|
+
next_required_minor_units:, reserve_status: nil, opens_at: nil, closes_at: nil,
|
|
140
|
+
last_effective_at: nil, reserve_minor_units: nil,
|
|
141
|
+
authorization_history: [], reserve_history: [],
|
|
142
|
+
voided_bid_ids: [], status: "open", result: nil, winner_id: nil,
|
|
143
|
+
winning_minor_units: nil)
|
|
144
|
+
@version = version
|
|
145
|
+
@positions = positions.each_with_object({}) do |(bidder_id, position), copy|
|
|
146
|
+
copy[bidder_id.to_s.freeze] = coerce_position(position)
|
|
147
|
+
end.freeze
|
|
148
|
+
@leader_id = leader_id&.to_s&.freeze
|
|
149
|
+
@standing_minor_units = standing_minor_units
|
|
150
|
+
@next_required_minor_units = next_required_minor_units
|
|
151
|
+
@reserve_status = reserve_status&.to_s&.freeze
|
|
152
|
+
@reserve_minor_units = reserve_minor_units
|
|
153
|
+
@opens_at = coerce_timestamp(opens_at, "opens_at")
|
|
154
|
+
@closes_at = coerce_timestamp(closes_at, "closes_at")
|
|
155
|
+
@last_effective_at = coerce_timestamp(last_effective_at, "last_effective_at")
|
|
156
|
+
@authorization_history = authorization_history.map do |entry|
|
|
157
|
+
coerce_authorization(entry)
|
|
158
|
+
end.freeze
|
|
159
|
+
@reserve_history = reserve_history.map do |entry|
|
|
160
|
+
coerce_reserve_change(entry)
|
|
161
|
+
end.freeze
|
|
162
|
+
@voided_bid_ids = voided_bid_ids.map { |bid_id| bid_id.to_s.freeze }.freeze
|
|
163
|
+
@status = status.to_s.freeze
|
|
164
|
+
@result = result&.to_s&.freeze
|
|
165
|
+
@winner_id = winner_id&.to_s&.freeze
|
|
166
|
+
@winning_minor_units = winning_minor_units
|
|
167
|
+
validate!
|
|
168
|
+
freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def position_for(bidder_id)
|
|
172
|
+
positions[bidder_id.to_s]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def bid_record_for(bid_id)
|
|
176
|
+
authorization_history.find do |entry|
|
|
177
|
+
entry.fetch("type") == "place_bid" && entry.fetch("bid_id") == bid_id.to_s
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def bidding_started?
|
|
182
|
+
authorization_history.any? { |entry| entry.fetch("type") == "place_bid" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def closed?
|
|
186
|
+
status == "closed"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Full privileged aggregate snapshot. Together with the host's
|
|
190
|
+
# auction_id, bidding_unit_id, and currency it satisfies
|
|
191
|
+
# specification/state/aggregate.schema.json and round-trips through
|
|
192
|
+
# `State.from_h`. It contains identities, maxima, the reserve amount, and
|
|
193
|
+
# audit history; never publish it.
|
|
194
|
+
def to_h
|
|
195
|
+
{
|
|
196
|
+
"version" => version,
|
|
197
|
+
"status" => status,
|
|
198
|
+
"opens_at" => Timestamp.dump(opens_at),
|
|
199
|
+
"closes_at" => Timestamp.dump(closes_at),
|
|
200
|
+
"last_effective_at" => Timestamp.dump(last_effective_at),
|
|
201
|
+
"reserve_minor_units" => reserve_minor_units,
|
|
202
|
+
"reserve_status" => reserve_status,
|
|
203
|
+
"leader_id" => leader_id,
|
|
204
|
+
"standing_minor_units" => standing_minor_units,
|
|
205
|
+
"next_required_minor_units" => next_required_minor_units,
|
|
206
|
+
"positions" => positions.transform_values(&:to_h),
|
|
207
|
+
"authorization_history" => authorization_history.map(&:dup),
|
|
208
|
+
"reserve_history" => reserve_history.map(&:dup),
|
|
209
|
+
"voided_bid_ids" => voided_bid_ids.dup,
|
|
210
|
+
"result" => result,
|
|
211
|
+
"winner_id" => winner_id,
|
|
212
|
+
"winning_minor_units" => winning_minor_units
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Public query projection. Together with the host's auction_id,
|
|
217
|
+
# bidding_unit_id, and currency it satisfies
|
|
218
|
+
# specification/state/bidding-unit.schema.json. It never carries a
|
|
219
|
+
# bidder, leader, or winner identity, a maximum, the reserve amount, or
|
|
220
|
+
# audit history.
|
|
221
|
+
def public_view
|
|
222
|
+
{
|
|
223
|
+
"version" => version,
|
|
224
|
+
"status" => status,
|
|
225
|
+
"opens_at" => Timestamp.dump(opens_at),
|
|
226
|
+
"closes_at" => Timestamp.dump(closes_at),
|
|
227
|
+
"standing_minor_units" => standing_minor_units,
|
|
228
|
+
"next_required_minor_units" => next_required_minor_units,
|
|
229
|
+
"reserve_status" => reserve_status,
|
|
230
|
+
"result" => result,
|
|
231
|
+
"winning_minor_units" => winning_minor_units
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def coerce_timestamp(value, field)
|
|
238
|
+
return nil if value.nil?
|
|
239
|
+
|
|
240
|
+
Timestamp.parse(value)
|
|
241
|
+
rescue ArgumentError
|
|
242
|
+
raise InvalidState, "#{field} must be a valid ISO 8601 timestamp"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def coerce_position(position)
|
|
246
|
+
return position if position.is_a?(Position) && position.frozen?
|
|
247
|
+
|
|
248
|
+
values = position.respond_to?(:to_h) ? position.to_h.transform_keys(&:to_s) : {}
|
|
249
|
+
Position.new(
|
|
250
|
+
maximum_minor_units: values.fetch("maximum_minor_units"),
|
|
251
|
+
priority: values.fetch("priority"),
|
|
252
|
+
executed_minor_units: values.fetch("executed_minor_units")
|
|
253
|
+
).freeze
|
|
254
|
+
rescue KeyError => e
|
|
255
|
+
raise InvalidState, "position is missing #{e.key}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def coerce_authorization(entry)
|
|
259
|
+
values = entry.respond_to?(:to_h) ? entry.to_h.transform_keys(&:to_s) : {}
|
|
260
|
+
normalized = {
|
|
261
|
+
"type" => values.fetch("type").to_s.freeze,
|
|
262
|
+
"bidder_id" => values.fetch("bidder_id").to_s.freeze,
|
|
263
|
+
"maximum_minor_units" => values.fetch("maximum_minor_units"),
|
|
264
|
+
"priority" => values.fetch("priority")
|
|
265
|
+
}
|
|
266
|
+
if normalized.fetch("type") == "place_bid"
|
|
267
|
+
normalized["bid_id"] = values.fetch("bid_id").to_s.freeze
|
|
268
|
+
end
|
|
269
|
+
normalized.freeze
|
|
270
|
+
rescue KeyError => e
|
|
271
|
+
raise InvalidState, "authorization is missing #{e.key}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def coerce_reserve_change(entry)
|
|
275
|
+
values = entry.respond_to?(:to_h) ? entry.to_h.transform_keys(&:to_s) : {}
|
|
276
|
+
{
|
|
277
|
+
"old_reserve_minor_units" => values.fetch("old_reserve_minor_units"),
|
|
278
|
+
"new_reserve_minor_units" => values.fetch("new_reserve_minor_units"),
|
|
279
|
+
"priority" => values.fetch("priority")
|
|
280
|
+
}.freeze
|
|
281
|
+
rescue KeyError => e
|
|
282
|
+
raise InvalidState, "reserve change is missing #{e.key}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def validate!
|
|
286
|
+
raise InvalidState, "version must be a non-negative integer" unless version.is_a?(Integer) && version >= 0
|
|
287
|
+
unless next_required_minor_units.is_a?(Integer) && next_required_minor_units >= 0
|
|
288
|
+
raise InvalidState, "next required amount must be a non-negative integer"
|
|
289
|
+
end
|
|
290
|
+
unless [nil, "reserve_not_met", "reserve_met"].include?(reserve_status)
|
|
291
|
+
raise InvalidState, "reserve status is invalid"
|
|
292
|
+
end
|
|
293
|
+
unless valid_optional_minor_units?(reserve_minor_units)
|
|
294
|
+
raise InvalidState, "reserve must be a non-negative integer or nil"
|
|
295
|
+
end
|
|
296
|
+
if reserve_minor_units.nil? != reserve_status.nil?
|
|
297
|
+
raise InvalidState, "reserve and reserve status must be present together"
|
|
298
|
+
end
|
|
299
|
+
raise InvalidState, "state is missing opens_at" if opens_at.nil?
|
|
300
|
+
raise InvalidState, "state is missing closes_at" if closes_at.nil?
|
|
301
|
+
if opens_at >= closes_at
|
|
302
|
+
raise InvalidState, "opens_at must be earlier than closes_at"
|
|
303
|
+
end
|
|
304
|
+
validate_authorization_history!
|
|
305
|
+
validate_reserve_history!
|
|
306
|
+
validate_lifecycle!
|
|
307
|
+
if leader_id && !positions.key?(leader_id)
|
|
308
|
+
raise InvalidState, "leader must have a position"
|
|
309
|
+
end
|
|
310
|
+
if leader_id.nil? != standing_minor_units.nil?
|
|
311
|
+
raise InvalidState, "leader and standing amount must be present together"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
positions.each_value do |position|
|
|
315
|
+
unless position.maximum_minor_units.is_a?(Integer) && position.maximum_minor_units >= 0
|
|
316
|
+
raise InvalidState, "maximums must be non-negative integers"
|
|
317
|
+
end
|
|
318
|
+
unless position.priority.is_a?(Integer) && position.priority.positive? && position.priority <= version
|
|
319
|
+
raise InvalidState, "position priority must belong to the applied event stream"
|
|
320
|
+
end
|
|
321
|
+
unless position.executed_minor_units.is_a?(Integer) &&
|
|
322
|
+
position.executed_minor_units.between?(0, position.maximum_minor_units)
|
|
323
|
+
raise InvalidState, "executed amount must be between zero and maximum"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
return unless leader_id
|
|
328
|
+
|
|
329
|
+
leader = positions.fetch(leader_id)
|
|
330
|
+
unless standing_minor_units.is_a?(Integer) &&
|
|
331
|
+
standing_minor_units.between?(0, leader.maximum_minor_units)
|
|
332
|
+
raise InvalidState, "standing amount must be within the leader maximum"
|
|
333
|
+
end
|
|
334
|
+
# The next required amount saturates at the interoperable bound, so the
|
|
335
|
+
# two are permitted to be equal only when both sit on that bound.
|
|
336
|
+
saturated = standing_minor_units == Money::MAX_MINOR_UNITS &&
|
|
337
|
+
next_required_minor_units == Money::MAX_MINOR_UNITS
|
|
338
|
+
unless saturated || next_required_minor_units > standing_minor_units
|
|
339
|
+
raise InvalidState, "next required amount must exceed standing amount"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
ranked_leader = positions.min_by do |bidder_id, position|
|
|
343
|
+
[-position.maximum_minor_units, position.priority, bidder_id]
|
|
344
|
+
end.first
|
|
345
|
+
raise InvalidState, "leader does not match maximum priority" unless ranked_leader == leader_id
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_authorization_history!
|
|
349
|
+
priorities = authorization_history.map { |entry| entry.fetch("priority") }
|
|
350
|
+
unless priorities.all? { |priority| priority.is_a?(Integer) }
|
|
351
|
+
raise InvalidState, "authorization priority must belong to the event stream"
|
|
352
|
+
end
|
|
353
|
+
unless priorities == priorities.sort && priorities.uniq == priorities
|
|
354
|
+
raise InvalidState, "authorization priorities must be unique and ordered"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
bid_ids = []
|
|
358
|
+
authorization_history.each do |entry|
|
|
359
|
+
unless %w[place_bid maximum_reduced].include?(entry.fetch("type"))
|
|
360
|
+
raise InvalidState, "authorization type is invalid"
|
|
361
|
+
end
|
|
362
|
+
unless entry.fetch("maximum_minor_units").is_a?(Integer) &&
|
|
363
|
+
entry.fetch("maximum_minor_units") >= 0
|
|
364
|
+
raise InvalidState, "authorization maximum must be a non-negative integer"
|
|
365
|
+
end
|
|
366
|
+
if entry.fetch("bidder_id").empty?
|
|
367
|
+
raise InvalidState, "authorization bidder ID must be present"
|
|
368
|
+
end
|
|
369
|
+
unless entry.fetch("priority").positive? && entry.fetch("priority") <= version
|
|
370
|
+
raise InvalidState, "authorization priority must belong to the event stream"
|
|
371
|
+
end
|
|
372
|
+
next unless entry.fetch("type") == "place_bid"
|
|
373
|
+
|
|
374
|
+
bid_id = entry.fetch("bid_id")
|
|
375
|
+
raise InvalidState, "authorization bid ID must be present" if bid_id.empty?
|
|
376
|
+
|
|
377
|
+
bid_ids << bid_id
|
|
378
|
+
end
|
|
379
|
+
raise InvalidState, "bid IDs must be unique" unless bid_ids.uniq == bid_ids
|
|
380
|
+
unless voided_bid_ids.uniq == voided_bid_ids
|
|
381
|
+
raise InvalidState, "voided bid IDs must be unique"
|
|
382
|
+
end
|
|
383
|
+
unless voided_bid_ids.all? { |bid_id| bid_ids.include?(bid_id) }
|
|
384
|
+
raise InvalidState, "voided bid IDs must refer to accepted bids"
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def validate_reserve_history!
|
|
389
|
+
priorities = reserve_history.map { |entry| entry.fetch("priority") }
|
|
390
|
+
valid_priorities = priorities.all? do |priority|
|
|
391
|
+
priority.is_a?(Integer) && priority.positive? && priority <= version
|
|
392
|
+
end
|
|
393
|
+
unless valid_priorities
|
|
394
|
+
raise InvalidState, "reserve change priority must belong to the event stream"
|
|
395
|
+
end
|
|
396
|
+
unless priorities == priorities.sort && priorities.uniq == priorities
|
|
397
|
+
raise InvalidState, "reserve change priorities must be unique and ordered"
|
|
398
|
+
end
|
|
399
|
+
unless (priorities & authorization_history.map { |entry| entry.fetch("priority") }).empty?
|
|
400
|
+
raise InvalidState, "state history priorities must be unique"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
reserve_history.each do |entry|
|
|
404
|
+
unless valid_optional_minor_units?(entry.fetch("old_reserve_minor_units")) &&
|
|
405
|
+
valid_optional_minor_units?(entry.fetch("new_reserve_minor_units"))
|
|
406
|
+
raise InvalidState, "reserve change values must be non-negative integers or nil"
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
reserve_history.each_cons(2) do |previous, current|
|
|
410
|
+
unless previous.fetch("new_reserve_minor_units") ==
|
|
411
|
+
current.fetch("old_reserve_minor_units")
|
|
412
|
+
raise InvalidState, "reserve change history must be continuous"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
if reserve_history.any? &&
|
|
416
|
+
reserve_history.last.fetch("new_reserve_minor_units") != reserve_minor_units
|
|
417
|
+
raise InvalidState, "current reserve must match reserve change history"
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def valid_optional_minor_units?(value)
|
|
422
|
+
value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def validate_lifecycle!
|
|
426
|
+
unless %w[open closed].include?(status)
|
|
427
|
+
raise InvalidState, "bidding status is invalid"
|
|
428
|
+
end
|
|
429
|
+
if status == "open"
|
|
430
|
+
unless result.nil? && winner_id.nil? && winning_minor_units.nil?
|
|
431
|
+
raise InvalidState, "open bidding cannot have a closing result"
|
|
432
|
+
end
|
|
433
|
+
return
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
unless %w[sold no_sale no_bid].include?(result)
|
|
437
|
+
raise InvalidState, "closed bidding result is invalid"
|
|
438
|
+
end
|
|
439
|
+
case result
|
|
440
|
+
when "sold"
|
|
441
|
+
unless leader_id && winner_id == leader_id && winning_minor_units == standing_minor_units
|
|
442
|
+
raise InvalidState, "sold result must match the standing leader and amount"
|
|
443
|
+
end
|
|
444
|
+
if reserve_status == "reserve_not_met"
|
|
445
|
+
raise InvalidState, "sold result requires reserve to be met"
|
|
446
|
+
end
|
|
447
|
+
when "no_sale"
|
|
448
|
+
unless leader_id && standing_minor_units && reserve_status == "reserve_not_met"
|
|
449
|
+
raise InvalidState, "no-sale result requires a leader below reserve"
|
|
450
|
+
end
|
|
451
|
+
unless winner_id.nil? && winning_minor_units.nil?
|
|
452
|
+
raise InvalidState, "no-sale result cannot have a winner"
|
|
453
|
+
end
|
|
454
|
+
when "no_bid"
|
|
455
|
+
unless leader_id.nil? && standing_minor_units.nil? &&
|
|
456
|
+
winner_id.nil? && winning_minor_units.nil?
|
|
457
|
+
raise InvalidState, "no-bid result cannot have a standing bid or winner"
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|