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.
@@ -0,0 +1,870 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBBB
4
+ # Deterministic RFC 0001 state transitions implemented to a claimed subset.
5
+ class Engine
6
+ SUPPORTED_COMMANDS = %w[
7
+ place_bid
8
+ reduce_maximum
9
+ void_bid
10
+ change_reserve
11
+ change_closing_time
12
+ close_bidding
13
+ ].freeze
14
+ NOTIFICATION_POLICIES = %w[affected removed_bidder all_bidders none].freeze
15
+ STATE_TRANSITION_EVENTS = %w[
16
+ maximum_accepted
17
+ maximum_increased
18
+ maximum_reduced
19
+ bid_voided
20
+ reserve_changed
21
+ closing_time_changed
22
+ bidding_closed
23
+ ].freeze
24
+
25
+ attr_reader :configuration
26
+
27
+ def initialize(configuration)
28
+ @configuration = configuration
29
+ unless configuration.is_a?(Configuration)
30
+ raise InvalidConfiguration, "engine requires an RBBB::Configuration"
31
+ end
32
+ end
33
+
34
+ def initial_state
35
+ State.empty(configuration)
36
+ end
37
+
38
+ def decide(state, command)
39
+ raise InvalidState, "engine requires an RBBB::State" unless state.is_a?(State)
40
+ return reject(nil, "invalid_command") unless command.respond_to?(:transform_keys)
41
+
42
+ values = command.transform_keys(&:to_s)
43
+ command_id = values["command_id"]
44
+ return reject(command_id, "unsupported_command") unless SUPPORTED_COMMANDS.include?(values["type"])
45
+ return reject(command_id, "invalid_command") unless present_string?(command_id)
46
+ if values.key?("expected_version") && values["expected_version"] != state.version
47
+ return reject(command_id, "stale_aggregate_version")
48
+ end
49
+ return reject(command_id, "bidding_closed") if state.closed?
50
+ if time_regressed?(state, authoritative_time(values))
51
+ return reject(command_id, "effective_at_out_of_order")
52
+ end
53
+
54
+ case values.fetch("type")
55
+ when "place_bid"
56
+ return reject(command_id, "invalid_command") unless present_string?(values["bidder_id"])
57
+
58
+ decide_place_bid(state, values)
59
+ when "reduce_maximum"
60
+ return reject(command_id, "invalid_command") unless present_string?(values["bidder_id"])
61
+
62
+ decide_reduce_maximum(state, values)
63
+ when "void_bid"
64
+ decide_void_bid(state, values)
65
+ when "change_reserve"
66
+ decide_change_reserve(state, values)
67
+ when "change_closing_time"
68
+ decide_change_closing_time(state, values)
69
+ when "close_bidding"
70
+ decide_close_bidding(state, values)
71
+ end
72
+ end
73
+
74
+ def apply(state, events)
75
+ transitions = events.select do |event|
76
+ event.privileged? && STATE_TRANSITION_EVENTS.include?(event.type)
77
+ end
78
+ if transitions.length > 1
79
+ raise InvalidState, "apply accepts the events of exactly one accepted command"
80
+ end
81
+
82
+ transition = transitions.first
83
+ return state unless transition
84
+
85
+ aggregate_version = transition.data.fetch("aggregate_version")
86
+ unless aggregate_version == state.version + 1
87
+ raise InvalidState, "event aggregate version is not the next version"
88
+ end
89
+ validate_transition_history!(state, transition, aggregate_version)
90
+
91
+ # Every transition carries the full aggregate snapshot, so the next state
92
+ # is exactly what a host would restore from this event.
93
+ State.from_transition(configuration, transition)
94
+ end
95
+
96
+ # Rebuild aggregate state from one privileged state-transition event
97
+ # instead of replaying the stream from version 0. Every transition event
98
+ # carries the full snapshot; the result is validated like any state.
99
+ def restore(event)
100
+ unless event.is_a?(Event) && event.privileged? && STATE_TRANSITION_EVENTS.include?(event.type)
101
+ raise InvalidState, "restore requires one privileged state-transition event"
102
+ end
103
+
104
+ State.from_transition(configuration, event)
105
+ end
106
+
107
+ private
108
+
109
+ def decide_place_bid(state, command)
110
+ bidder_id = command.fetch("bidder_id").to_s
111
+ bid_id = command["bid_id"] || command.fetch("command_id")
112
+ maximum = command["maximum_minor_units"]
113
+ command_id = command["command_id"]
114
+ return reject(command_id, "invalid_command") unless present_string?(bid_id)
115
+ return reject(command_id, "bid_id_already_exists") if state.bid_record_for(bid_id)
116
+
117
+ effective_at = authoritative_time(command)
118
+ timing_rejection = reject_for_timing(state, command_id, effective_at, bidder: true)
119
+ return timing_rejection if timing_rejection
120
+ return reject(command_id, "invalid_maximum") unless Money.amount?(maximum)
121
+
122
+ existing = state.position_for(bidder_id)
123
+ if state.positions.empty? && maximum < configuration.opening_minor_units
124
+ return reject(command_id, "maximum_below_opening")
125
+ end
126
+ if existing && maximum <= existing.maximum_minor_units
127
+ return reject(command_id, "maximum_not_increased")
128
+ end
129
+ if state.leader_id && bidder_id != state.leader_id &&
130
+ maximum < state.next_required_minor_units
131
+ return reject(command_id, "maximum_below_next_required")
132
+ end
133
+
134
+ build_bid_decision(state, command, bid_id, bidder_id, maximum, existing, effective_at)
135
+ end
136
+
137
+ def decide_reduce_maximum(state, command)
138
+ bidder_id = command.fetch("bidder_id").to_s
139
+ maximum = command["maximum_minor_units"]
140
+ command_id = command["command_id"]
141
+ effective_at = authoritative_time(command)
142
+ timing_rejection = reject_for_timing(state, command_id, effective_at, bidder: true)
143
+ return timing_rejection if timing_rejection
144
+ return reject(command_id, "invalid_maximum") unless Money.amount?(maximum)
145
+
146
+ existing = state.position_for(bidder_id)
147
+ return reject(command_id, "maximum_not_found") unless existing
148
+ if maximum >= existing.maximum_minor_units
149
+ return reject(command_id, "maximum_not_reduced")
150
+ end
151
+ if maximum < existing.executed_minor_units
152
+ return reject(
153
+ command_id,
154
+ "maximum_below_executed_amount",
155
+ executed_floor_minor_units: existing.executed_minor_units
156
+ )
157
+ end
158
+
159
+ build_reduction_decision(state, command, bidder_id, maximum, existing, effective_at)
160
+ end
161
+
162
+ def decide_void_bid(state, command)
163
+ command_id = command.fetch("command_id")
164
+ bid_id = command["bid_id"]
165
+ operator_id = command["operator_id"]
166
+ reason = command["reason"]
167
+ policy = command["notification_policy"]
168
+ unless present_string?(bid_id) && present_string?(operator_id) && present_string?(reason)
169
+ return reject(command_id, "invalid_command")
170
+ end
171
+ unless NOTIFICATION_POLICIES.include?(policy)
172
+ return reject(command_id, "invalid_notification_policy")
173
+ end
174
+
175
+ effective_at = authoritative_time(command)
176
+ timing_rejection = reject_for_timing(state, command_id, effective_at)
177
+ return timing_rejection if timing_rejection
178
+
179
+ target = state.bid_record_for(bid_id)
180
+ return reject(command_id, "bid_not_found") unless target
181
+ return reject(command_id, "bid_already_voided") if state.voided_bid_ids.include?(bid_id)
182
+
183
+ build_void_decision(state, command, target, effective_at)
184
+ end
185
+
186
+ def decide_change_reserve(state, command)
187
+ command_id = command.fetch("command_id")
188
+ unless valid_operator_command?(command) && command.key?("reserve_minor_units")
189
+ return reject(command_id, "invalid_command")
190
+ end
191
+
192
+ new_reserve = command["reserve_minor_units"]
193
+ unless new_reserve.nil? || Money.amount?(new_reserve)
194
+ return reject(command_id, "invalid_reserve")
195
+ end
196
+
197
+ effective_at = authoritative_time(command)
198
+ timing_rejection = reject_for_timing(state, command_id, effective_at)
199
+ return timing_rejection if timing_rejection
200
+
201
+ if state.bidding_started? && reserve_increase?(state.reserve_minor_units, new_reserve)
202
+ return reject(command_id, "reserve_may_not_increase_after_first_bid")
203
+ end
204
+
205
+ build_reserve_change_decision(state, command, new_reserve, effective_at)
206
+ end
207
+
208
+ def decide_change_closing_time(state, command)
209
+ command_id = command.fetch("command_id")
210
+ unless valid_operator_command?(command) && present_string?(command["closes_at"])
211
+ return reject(command_id, "invalid_command")
212
+ end
213
+
214
+ new_closes_at = Timestamp.parse(command.fetch("closes_at"))
215
+ effective_at = authoritative_time(command)
216
+ return reject(command_id, "invalid_command") unless effective_at
217
+
218
+ timing_rejection = reject_for_timing(state, command_id, effective_at)
219
+ return timing_rejection if timing_rejection
220
+ if new_closes_at <= effective_at || new_closes_at <= state.opens_at
221
+ return reject(command_id, "invalid_closing_time")
222
+ end
223
+ if state.bidding_started? && new_closes_at < state.closes_at
224
+ return reject(command_id, "closing_time_may_not_shorten_after_first_bid")
225
+ end
226
+
227
+ build_closing_time_change_decision(state, command, new_closes_at, effective_at)
228
+ rescue ArgumentError
229
+ reject(command_id, "invalid_closing_time")
230
+ end
231
+
232
+ def decide_close_bidding(state, command)
233
+ command_id = command.fetch("command_id")
234
+ effective_at = authoritative_time(command)
235
+ return reject(command_id, "invalid_command") unless effective_at
236
+ if effective_at < state.closes_at
237
+ return reject(command_id, "closing_time_not_reached")
238
+ end
239
+
240
+ build_closing_decision(state, command, effective_at)
241
+ end
242
+
243
+ def build_bid_decision(state, command, bid_id, bidder_id, maximum, existing, effective_at)
244
+ aggregate_version = state.version + 1
245
+ authorization_history = state.authorization_history.map(&:dup)
246
+ authorization_history << {
247
+ "type" => "place_bid",
248
+ "bid_id" => bid_id,
249
+ "bidder_id" => bidder_id,
250
+ "maximum_minor_units" => maximum,
251
+ "priority" => aggregate_version
252
+ }
253
+ positions = state.positions.transform_values(&:to_h)
254
+ positions[bidder_id] = {
255
+ "maximum_minor_units" => maximum,
256
+ "priority" => aggregate_version,
257
+ "executed_minor_units" => existing&.executed_minor_units || 0
258
+ }
259
+
260
+ ranked = positions.sort_by do |id, position|
261
+ [-position.fetch("maximum_minor_units"), position.fetch("priority"), id]
262
+ end
263
+ leader_id = ranked.fetch(0).first
264
+ standing = standing_amount(ranked, state.reserve_minor_units)
265
+ standing = [standing, state.standing_minor_units || 0].max
266
+ reserve_status = reserve_status_for(ranked.fetch(0).last, state.reserve_minor_units)
267
+ next_required = next_required_for(standing)
268
+ public_result_changed = standing != state.standing_minor_units ||
269
+ leader_id != state.leader_id
270
+ closes_at = extended_closing_time(state, effective_at, public_result_changed)
271
+
272
+ positions.each do |id, position|
273
+ executed = id == leader_id ? standing : position.fetch("maximum_minor_units")
274
+ position["executed_minor_units"] = [
275
+ position.fetch("executed_minor_units"),
276
+ executed
277
+ ].max
278
+ end
279
+
280
+ event_type = existing ? "maximum_increased" : "maximum_accepted"
281
+ privileged = Event.new(
282
+ type: event_type,
283
+ visibility: :privileged,
284
+ data: {
285
+ "command_id" => command.fetch("command_id"),
286
+ "aggregate_version" => aggregate_version,
287
+ "bid_id" => bid_id,
288
+ "bidder_id" => bidder_id,
289
+ "maximum_minor_units" => maximum,
290
+ "executed_minor_units" => positions.fetch(bidder_id).fetch("executed_minor_units"),
291
+ "leader_id" => leader_id,
292
+ "standing_minor_units" => standing,
293
+ "next_required_minor_units" => next_required,
294
+ "reserve_status" => reserve_status,
295
+ "reserve_minor_units" => state.reserve_minor_units,
296
+ "effective_at" => Timestamp.dump(effective_at),
297
+ "closes_at" => Timestamp.dump(closes_at),
298
+ "positions" => positions,
299
+ "authorization_history" => authorization_history,
300
+ "reserve_history" => state.reserve_history,
301
+ "voided_bid_ids" => state.voided_bid_ids
302
+ }
303
+ )
304
+
305
+ events = [privileged]
306
+ if standing != state.standing_minor_units || leader_id != state.leader_id ||
307
+ reserve_status != state.reserve_status
308
+ public_data = {
309
+ "command_id" => command.fetch("command_id"),
310
+ "aggregate_version" => aggregate_version,
311
+ "standing_minor_units" => standing,
312
+ "next_required_minor_units" => next_required,
313
+ "leader_changed" => leader_id != state.leader_id
314
+ }
315
+ public_data["reserve_status"] = reserve_status if reserve_status
316
+ events << Event.new(
317
+ type: "standing_bid_changed",
318
+ visibility: :public,
319
+ data: public_data
320
+ )
321
+ end
322
+ if closes_at != state.closes_at
323
+ events << Event.new(
324
+ type: "closing_time_changed",
325
+ visibility: :public,
326
+ data: {
327
+ "command_id" => command.fetch("command_id"),
328
+ "aggregate_version" => aggregate_version,
329
+ "closes_at" => Timestamp.dump(closes_at)
330
+ }
331
+ )
332
+ end
333
+
334
+ Decision.accepted(events)
335
+ end
336
+
337
+ def build_reduction_decision(state, command, bidder_id, maximum, existing, effective_at)
338
+ aggregate_version = state.version + 1
339
+ authorization_history = state.authorization_history.map(&:dup)
340
+ authorization_history << {
341
+ "type" => "maximum_reduced",
342
+ "bidder_id" => bidder_id,
343
+ "maximum_minor_units" => maximum,
344
+ "priority" => aggregate_version
345
+ }
346
+ positions = state.positions.transform_values(&:to_h)
347
+ positions[bidder_id] = {
348
+ "maximum_minor_units" => maximum,
349
+ "priority" => aggregate_version,
350
+ "executed_minor_units" => existing.executed_minor_units
351
+ }
352
+
353
+ event = Event.new(
354
+ type: "maximum_reduced",
355
+ visibility: :privileged,
356
+ data: {
357
+ "command_id" => command.fetch("command_id"),
358
+ "aggregate_version" => aggregate_version,
359
+ "bidder_id" => bidder_id,
360
+ "old_maximum_minor_units" => existing.maximum_minor_units,
361
+ "new_maximum_minor_units" => maximum,
362
+ "executed_floor_minor_units" => existing.executed_minor_units,
363
+ "leader_id" => state.leader_id,
364
+ "standing_minor_units" => state.standing_minor_units,
365
+ "next_required_minor_units" => state.next_required_minor_units,
366
+ "reserve_status" => state.reserve_status,
367
+ "reserve_minor_units" => state.reserve_minor_units,
368
+ "effective_at" => Timestamp.dump(effective_at),
369
+ "closes_at" => Timestamp.dump(state.closes_at),
370
+ "positions" => positions,
371
+ "authorization_history" => authorization_history,
372
+ "reserve_history" => state.reserve_history,
373
+ "voided_bid_ids" => state.voided_bid_ids
374
+ }
375
+ )
376
+
377
+ Decision.accepted([event])
378
+ end
379
+
380
+ def build_reserve_change_decision(state, command, new_reserve, effective_at)
381
+ aggregate_version = state.version + 1
382
+ reserve_history = state.reserve_history.map(&:dup)
383
+ reserve_history << {
384
+ "old_reserve_minor_units" => state.reserve_minor_units,
385
+ "new_reserve_minor_units" => new_reserve,
386
+ "priority" => aggregate_version
387
+ }
388
+ leader = state.position_for(state.leader_id)
389
+ reserve_status = reserve_status_for(leader&.to_h, new_reserve)
390
+ common_data = {
391
+ "command_id" => command.fetch("command_id"),
392
+ "aggregate_version" => aggregate_version
393
+ }
394
+ transition = Event.new(
395
+ type: "reserve_changed",
396
+ visibility: :privileged,
397
+ data: common_data.merge(
398
+ "operator_id" => command.fetch("operator_id"),
399
+ "reason" => command.fetch("reason"),
400
+ "old_reserve_minor_units" => state.reserve_minor_units,
401
+ "new_reserve_minor_units" => new_reserve,
402
+ "effective_at" => Timestamp.dump(effective_at),
403
+ "leader_id" => state.leader_id,
404
+ "standing_minor_units" => state.standing_minor_units,
405
+ "next_required_minor_units" => state.next_required_minor_units,
406
+ "reserve_status" => reserve_status,
407
+ "reserve_minor_units" => new_reserve,
408
+ "closes_at" => Timestamp.dump(state.closes_at),
409
+ "positions" => state.positions.transform_values(&:to_h),
410
+ "authorization_history" => state.authorization_history,
411
+ "reserve_history" => reserve_history,
412
+ "voided_bid_ids" => state.voided_bid_ids
413
+ )
414
+ )
415
+
416
+ events = [transition]
417
+ if reserve_status != state.reserve_status
418
+ events << Event.new(
419
+ type: "reserve_status_changed",
420
+ visibility: :public,
421
+ data: common_data.merge("reserve_status" => reserve_status)
422
+ )
423
+ end
424
+
425
+ Decision.accepted(events)
426
+ end
427
+
428
+ def build_closing_time_change_decision(state, command, new_closes_at, effective_at)
429
+ aggregate_version = state.version + 1
430
+ old_closes_at = state.closes_at
431
+ common_data = {
432
+ "command_id" => command.fetch("command_id"),
433
+ "aggregate_version" => aggregate_version,
434
+ "old_closes_at" => Timestamp.dump(old_closes_at),
435
+ "closes_at" => Timestamp.dump(new_closes_at)
436
+ }
437
+ transition = Event.new(
438
+ type: "closing_time_changed",
439
+ visibility: :privileged,
440
+ data: common_data.merge(
441
+ "operator_id" => command.fetch("operator_id"),
442
+ "reason" => command.fetch("reason"),
443
+ "effective_at" => Timestamp.dump(effective_at),
444
+ "leader_id" => state.leader_id,
445
+ "standing_minor_units" => state.standing_minor_units,
446
+ "next_required_minor_units" => state.next_required_minor_units,
447
+ "reserve_status" => state.reserve_status,
448
+ "reserve_minor_units" => state.reserve_minor_units,
449
+ "positions" => state.positions.transform_values(&:to_h),
450
+ "authorization_history" => state.authorization_history,
451
+ "reserve_history" => state.reserve_history,
452
+ "voided_bid_ids" => state.voided_bid_ids
453
+ )
454
+ )
455
+ public_event = Event.new(
456
+ type: "closing_time_changed",
457
+ visibility: :public,
458
+ data: common_data
459
+ )
460
+
461
+ Decision.accepted([transition, public_event])
462
+ end
463
+
464
+ def build_closing_decision(state, command, effective_at)
465
+ aggregate_version = state.version + 1
466
+ result = closing_result(state)
467
+ winner_id = result == "sold" ? state.leader_id : nil
468
+ winning_minor_units = result == "sold" ? state.standing_minor_units : nil
469
+ common_data = {
470
+ "command_id" => command.fetch("command_id"),
471
+ "aggregate_version" => aggregate_version,
472
+ "result" => result
473
+ }
474
+ transition = Event.new(
475
+ type: "bidding_closed",
476
+ visibility: :privileged,
477
+ data: common_data.merge(
478
+ "effective_at" => Timestamp.dump(effective_at),
479
+ "leader_id" => state.leader_id,
480
+ "standing_minor_units" => state.standing_minor_units,
481
+ "next_required_minor_units" => state.next_required_minor_units,
482
+ "reserve_status" => state.reserve_status,
483
+ "reserve_minor_units" => state.reserve_minor_units,
484
+ "closes_at" => Timestamp.dump(state.closes_at),
485
+ "positions" => state.positions.transform_values(&:to_h),
486
+ "authorization_history" => state.authorization_history,
487
+ "reserve_history" => state.reserve_history,
488
+ "voided_bid_ids" => state.voided_bid_ids,
489
+ "status" => "closed",
490
+ "winner_id" => winner_id,
491
+ "winning_minor_units" => winning_minor_units
492
+ )
493
+ )
494
+ public_data = common_data.dup
495
+ case result
496
+ when "sold"
497
+ public_data["winning_minor_units"] = winning_minor_units
498
+ when "no_sale"
499
+ public_data["standing_minor_units"] = state.standing_minor_units
500
+ end
501
+ public_event = Event.new(
502
+ type: "bidding_closed",
503
+ visibility: :public,
504
+ data: public_data
505
+ )
506
+
507
+ Decision.accepted([transition, public_event])
508
+ end
509
+
510
+ def build_void_decision(state, command, target, effective_at)
511
+ aggregate_version = state.version + 1
512
+ voided_bid_ids = [*state.voided_bid_ids, command.fetch("bid_id")]
513
+ recomputed = replay_authorizations(
514
+ state.authorization_history,
515
+ voided_bid_ids,
516
+ state.reserve_history
517
+ )
518
+ common_data = {
519
+ "command_id" => command.fetch("command_id"),
520
+ "aggregate_version" => aggregate_version
521
+ }
522
+
523
+ events = [Event.new(
524
+ type: "bid_voided",
525
+ visibility: :privileged,
526
+ data: common_data.merge(
527
+ "bid_id" => command.fetch("bid_id"),
528
+ "bidder_id" => target.fetch("bidder_id"),
529
+ "operator_id" => command.fetch("operator_id"),
530
+ "reason" => command.fetch("reason"),
531
+ "notification_policy" => command.fetch("notification_policy"),
532
+ "effective_at" => Timestamp.dump(effective_at),
533
+ "leader_id" => recomputed.fetch("leader_id"),
534
+ "standing_minor_units" => recomputed.fetch("standing_minor_units"),
535
+ "next_required_minor_units" => recomputed.fetch("next_required_minor_units"),
536
+ "reserve_status" => recomputed.fetch("reserve_status"),
537
+ "reserve_minor_units" => state.reserve_minor_units,
538
+ "closes_at" => Timestamp.dump(state.closes_at),
539
+ "positions" => recomputed.fetch("positions"),
540
+ "authorization_history" => state.authorization_history,
541
+ "reserve_history" => state.reserve_history,
542
+ "voided_bid_ids" => voided_bid_ids
543
+ )
544
+ )]
545
+
546
+ executed_amount_changes(state, recomputed).each do |change|
547
+ events << Event.new(
548
+ type: "executed_amount_changed",
549
+ visibility: :privileged,
550
+ data: common_data.merge(change)
551
+ )
552
+ end
553
+
554
+ if public_state_changed?(state, recomputed)
555
+ public_data = common_data.merge(
556
+ "standing_minor_units" => recomputed.fetch("standing_minor_units"),
557
+ "next_required_minor_units" => recomputed.fetch("next_required_minor_units"),
558
+ "leader_changed" => state.leader_id != recomputed.fetch("leader_id")
559
+ )
560
+ reserve_status = recomputed.fetch("reserve_status")
561
+ public_data["reserve_status"] = reserve_status if reserve_status
562
+ events << Event.new(
563
+ type: "standing_bid_changed",
564
+ visibility: :public,
565
+ data: public_data
566
+ )
567
+ end
568
+
569
+ events << Event.new(
570
+ type: "notification_requested",
571
+ visibility: :privileged,
572
+ data: common_data.merge(
573
+ "bid_id" => command.fetch("bid_id"),
574
+ "reason" => command.fetch("reason"),
575
+ "policy" => command.fetch("notification_policy"),
576
+ "bidder_ids" => notification_recipients(
577
+ state,
578
+ recomputed,
579
+ target,
580
+ command.fetch("notification_policy")
581
+ )
582
+ )
583
+ )
584
+
585
+ Decision.accepted(events)
586
+ end
587
+
588
+ def replay_authorizations(history, voided_bid_ids, reserve_history)
589
+ positions = {}
590
+ reserve_minor_units = configuration.reserve_minor_units
591
+ actions = [
592
+ *history,
593
+ *reserve_history.map { |entry| entry.merge("type" => "reserve_changed") }
594
+ ].sort_by { |entry| entry.fetch("priority") }
595
+
596
+ actions.each do |entry|
597
+ if entry.fetch("type") == "reserve_changed"
598
+ reserve_minor_units = entry.fetch("new_reserve_minor_units")
599
+ next
600
+ end
601
+
602
+ bidder_id = entry.fetch("bidder_id")
603
+ maximum = entry.fetch("maximum_minor_units")
604
+ if entry.fetch("type") == "place_bid"
605
+ next if voided_bid_ids.include?(entry.fetch("bid_id"))
606
+
607
+ existing = positions[bidder_id]
608
+ positions[bidder_id] = {
609
+ "maximum_minor_units" => maximum,
610
+ "priority" => entry.fetch("priority"),
611
+ "executed_minor_units" => existing&.fetch("executed_minor_units") || 0
612
+ }
613
+ snapshot = price_positions(positions, reserve_minor_units)
614
+ apply_executions!(positions, snapshot)
615
+ else
616
+ existing = positions[bidder_id]
617
+ next unless existing
618
+ next unless maximum < existing.fetch("maximum_minor_units")
619
+ next unless maximum >= existing.fetch("executed_minor_units")
620
+
621
+ existing["maximum_minor_units"] = maximum
622
+ existing["priority"] = entry.fetch("priority")
623
+ end
624
+ end
625
+
626
+ if positions.empty?
627
+ return empty_pricing_snapshot(reserve_minor_units).merge("positions" => {})
628
+ end
629
+
630
+ price_positions(positions, reserve_minor_units).merge("positions" => positions)
631
+ end
632
+
633
+ def price_positions(positions, reserve_minor_units)
634
+ ranked = positions.sort_by do |id, position|
635
+ [-position.fetch("maximum_minor_units"), position.fetch("priority"), id]
636
+ end
637
+ leader_id = ranked.fetch(0).first
638
+ standing = standing_amount(ranked, reserve_minor_units)
639
+ standing = [
640
+ standing,
641
+ positions.fetch(leader_id).fetch("executed_minor_units")
642
+ ].max
643
+ {
644
+ "leader_id" => leader_id,
645
+ "standing_minor_units" => standing,
646
+ "next_required_minor_units" => next_required_for(standing),
647
+ "reserve_status" => reserve_status_for(ranked.fetch(0).last, reserve_minor_units)
648
+ }
649
+ end
650
+
651
+ def empty_pricing_snapshot(reserve_minor_units)
652
+ {
653
+ "leader_id" => nil,
654
+ "standing_minor_units" => nil,
655
+ "next_required_minor_units" => configuration.opening_minor_units,
656
+ "reserve_status" => reserve_minor_units ? "reserve_not_met" : nil
657
+ }
658
+ end
659
+
660
+ def apply_executions!(positions, snapshot)
661
+ leader_id = snapshot.fetch("leader_id")
662
+ standing = snapshot.fetch("standing_minor_units")
663
+ positions.each do |id, position|
664
+ executed = id == leader_id ? standing : position.fetch("maximum_minor_units")
665
+ position["executed_minor_units"] = [
666
+ position.fetch("executed_minor_units"),
667
+ executed
668
+ ].max
669
+ end
670
+ end
671
+
672
+ def executed_amount_changes(state, recomputed)
673
+ recomputed.fetch("positions").keys.sort.filter_map do |bidder_id|
674
+ old_position = state.position_for(bidder_id)
675
+ new_amount = recomputed.fetch("positions").fetch(bidder_id).fetch("executed_minor_units")
676
+ next unless old_position && old_position.executed_minor_units != new_amount
677
+
678
+ {
679
+ "bidder_id" => bidder_id,
680
+ "old_minor_units" => old_position.executed_minor_units,
681
+ "new_minor_units" => new_amount
682
+ }
683
+ end
684
+ end
685
+
686
+ def public_state_changed?(state, recomputed)
687
+ state.standing_minor_units != recomputed.fetch("standing_minor_units") ||
688
+ state.leader_id != recomputed.fetch("leader_id") ||
689
+ state.reserve_status != recomputed.fetch("reserve_status")
690
+ end
691
+
692
+ def notification_recipients(state, recomputed, target, policy)
693
+ case policy
694
+ when "affected"
695
+ bidders = [target.fetch("bidder_id")]
696
+ if state.leader_id != recomputed.fetch("leader_id")
697
+ bidders.concat([state.leader_id, recomputed.fetch("leader_id")])
698
+ end
699
+ bidders.compact.uniq.sort
700
+ when "removed_bidder"
701
+ [target.fetch("bidder_id")]
702
+ when "all_bidders"
703
+ state.authorization_history.filter_map do |entry|
704
+ entry.fetch("bidder_id") if entry.fetch("type") == "place_bid"
705
+ end.uniq.sort
706
+ when "none"
707
+ []
708
+ end
709
+ end
710
+
711
+ def authoritative_time(command)
712
+ value = command["effective_at"]
713
+ return nil if value.nil?
714
+
715
+ Timestamp.parse(value)
716
+ rescue ArgumentError
717
+ nil
718
+ end
719
+
720
+ def reject_for_timing(state, command_id, effective_at, bidder: false)
721
+ return reject(command_id, "invalid_command") unless effective_at
722
+ if bidder && effective_at < state.opens_at
723
+ return reject(command_id, "bidding_not_open")
724
+ end
725
+ return reject(command_id, "bidding_closed") if effective_at >= state.closes_at
726
+
727
+ nil
728
+ end
729
+
730
+ def time_regressed?(state, effective_at)
731
+ return false unless effective_at && state.last_effective_at
732
+
733
+ effective_at < state.last_effective_at
734
+ end
735
+
736
+ def extended_closing_time(state, effective_at, public_result_changed)
737
+ return state.closes_at unless configuration.extension
738
+ return state.closes_at unless public_result_changed && effective_at
739
+
740
+ trigger_window = configuration.extension.fetch("trigger_window_seconds")
741
+ return state.closes_at if effective_at < state.closes_at - trigger_window
742
+
743
+ # An extension may only push closing later. A duration shorter than the
744
+ # remaining time must never pull the close forward.
745
+ [state.closes_at, effective_at + configuration.extension.fetch("duration_seconds")].max
746
+ end
747
+
748
+ # The next required amount saturates at the interoperable bound so that
749
+ # no derived amount ever exceeds what a maximum may carry.
750
+ def next_required_for(standing)
751
+ [standing + configuration.increment_for(standing), Money::MAX_MINOR_UNITS].min
752
+ end
753
+
754
+ def standing_amount(ranked, reserve_minor_units)
755
+ leader = ranked.fetch(0).last
756
+ competitive = if ranked.one?
757
+ configuration.opening_minor_units
758
+ else
759
+ runner_up = ranked.fetch(1).last
760
+ runner_up.fetch("maximum_minor_units") +
761
+ configuration.increment_for(runner_up.fetch("maximum_minor_units"))
762
+ end
763
+ reserve_pressure = if reserve_minor_units
764
+ [leader.fetch("maximum_minor_units"), reserve_minor_units].min
765
+ else
766
+ configuration.opening_minor_units
767
+ end
768
+ [
769
+ leader.fetch("maximum_minor_units"),
770
+ [configuration.opening_minor_units, competitive, reserve_pressure].max
771
+ ].min
772
+ end
773
+
774
+ def reserve_status_for(leader, reserve_minor_units)
775
+ return nil unless reserve_minor_units
776
+ return "reserve_not_met" unless leader
777
+
778
+ if leader.fetch("maximum_minor_units") >= reserve_minor_units
779
+ "reserve_met"
780
+ else
781
+ "reserve_not_met"
782
+ end
783
+ end
784
+
785
+ def reject(command_id, reason, **fields)
786
+ Decision.rejected(command_id: command_id, reason: reason, **fields)
787
+ end
788
+
789
+ def present_string?(value)
790
+ value.is_a?(String) && !value.empty?
791
+ end
792
+
793
+ def valid_operator_command?(command)
794
+ present_string?(command["operator_id"]) && present_string?(command["reason"])
795
+ end
796
+
797
+ def reserve_increase?(old_reserve, new_reserve)
798
+ return false if new_reserve.nil?
799
+ return true if old_reserve.nil?
800
+
801
+ new_reserve > old_reserve
802
+ end
803
+
804
+ def closing_result(state)
805
+ return "no_bid" unless state.leader_id
806
+ return "no_sale" if state.reserve_status == "reserve_not_met"
807
+
808
+ "sold"
809
+ end
810
+
811
+ def validate_transition_history!(state, transition, aggregate_version)
812
+ case transition.type
813
+ when "reserve_changed"
814
+ unless transition.data.fetch("old_reserve_minor_units") == state.reserve_minor_units
815
+ raise InvalidState, "reserve change does not start from current reserve"
816
+ end
817
+ history = transition.data.fetch("reserve_history")
818
+ unless history.length == state.reserve_history.length + 1
819
+ raise InvalidState, "reserve change must append one history entry"
820
+ end
821
+ latest = history.last
822
+ unless latest.fetch("priority") == aggregate_version &&
823
+ latest.fetch("old_reserve_minor_units") == state.reserve_minor_units &&
824
+ latest.fetch("new_reserve_minor_units") ==
825
+ transition.data.fetch("new_reserve_minor_units")
826
+ raise InvalidState, "reserve change history does not match transition"
827
+ end
828
+ when "closing_time_changed"
829
+ unless transition.data.fetch("old_closes_at") == Timestamp.dump(state.closes_at)
830
+ raise InvalidState, "closing-time change does not start from current close"
831
+ end
832
+ when "bidding_closed"
833
+ validate_closing_transition!(state, transition)
834
+ end
835
+ end
836
+
837
+ def validate_closing_transition!(state, transition)
838
+ effective_at = Timestamp.parse(transition.data.fetch("effective_at"))
839
+ if effective_at < state.closes_at
840
+ raise InvalidState, "closing transition precedes current close"
841
+ end
842
+
843
+ expected_result = closing_result(state)
844
+ expected_winner = expected_result == "sold" ? state.leader_id : nil
845
+ expected_winning_amount = expected_result == "sold" ? state.standing_minor_units : nil
846
+ expected_snapshot = {
847
+ "leader_id" => state.leader_id,
848
+ "standing_minor_units" => state.standing_minor_units,
849
+ "next_required_minor_units" => state.next_required_minor_units,
850
+ "reserve_status" => state.reserve_status,
851
+ "reserve_minor_units" => state.reserve_minor_units,
852
+ "closes_at" => Timestamp.dump(state.closes_at),
853
+ "positions" => state.positions.transform_values(&:to_h),
854
+ "authorization_history" => state.authorization_history,
855
+ "reserve_history" => state.reserve_history,
856
+ "voided_bid_ids" => state.voided_bid_ids,
857
+ "status" => "closed",
858
+ "result" => expected_result,
859
+ "winner_id" => expected_winner,
860
+ "winning_minor_units" => expected_winning_amount
861
+ }
862
+ snapshot_matches = expected_snapshot.all? do |key, expected|
863
+ transition.data.fetch(key) == expected
864
+ end
865
+ raise InvalidState, "closing transition does not match current state" unless snapshot_matches
866
+ rescue ArgumentError, KeyError
867
+ raise InvalidState, "closing transition is invalid"
868
+ end
869
+ end
870
+ end