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/engine.rb
ADDED
|
@@ -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
|