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.
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