spree_cm_commissioner 2.15.2 → 2.15.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +3 -0
  3. data/Gemfile.lock +2 -2
  4. data/app/controllers/spree/admin/inventory_holds_controller.rb +32 -6
  5. data/app/errors/spree_cm_commissioner/integrations/rate_limited_error.rb +28 -0
  6. data/app/jobs/spree_cm_commissioner/integrations/book_me_bus_v1/sync_route_job.rb +70 -0
  7. data/app/models/spree_cm_commissioner/block.rb +16 -0
  8. data/app/models/spree_cm_commissioner/integration_mapping.rb +8 -0
  9. data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +5 -0
  10. data/app/models/spree_cm_commissioner/reserved_block.rb +19 -3
  11. data/app/queries/spree_cm_commissioner/inventory_hold_insights_query.rb +151 -0
  12. data/app/queries/spree_cm_commissioner/inventory_hold_overview_query.rb +79 -0
  13. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +7 -0
  14. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +24 -7
  15. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/connection.rb +19 -13
  16. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +12 -6
  17. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +10 -3
  18. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +11 -2
  19. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +8 -59
  20. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +55 -35
  21. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +0 -4
  22. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/route_freshness.rb +48 -0
  23. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/seat_layout_freshness.rb +46 -0
  24. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_manager.rb +5 -6
  25. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +32 -15
  26. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +43 -40
  27. data/app/services/spree_cm_commissioner/integrations/call_limiter.rb +89 -0
  28. data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +13 -7
  29. data/app/services/spree_cm_commissioner/integrations/sync_backoff.rb +61 -0
  30. data/app/services/spree_cm_commissioner/seats/apply_locks.rb +13 -7
  31. data/app/services/spree_cm_commissioner/seats/block_history.rb +168 -0
  32. data/app/services/spree_cm_commissioner/seats/lock_state.rb +3 -1
  33. data/app/services/spree_cm_commissioner/seats/swap_seats.rb +265 -0
  34. data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +11 -12
  35. data/app/views/spree/admin/inventory_holds/_insights.html.erb +99 -0
  36. data/app/views/spree/admin/inventory_holds/_insights_drawer.html.erb +83 -0
  37. data/app/views/spree/admin/inventory_holds/_order_status.html.erb +9 -0
  38. data/app/views/spree/admin/inventory_holds/_overview.html.erb +109 -0
  39. data/app/views/spree/admin/inventory_holds/_section.html.erb +5 -0
  40. data/app/views/spree/admin/inventory_holds/_stat.html.erb +4 -0
  41. data/app/views/spree/admin/inventory_holds/index.html.erb +12 -1
  42. data/config/routes.rb +4 -0
  43. data/lib/spree_cm_commissioner/version.rb +1 -1
  44. metadata +18 -2
@@ -0,0 +1,265 @@
1
+ require 'zlib'
2
+
3
+ module SpreeCmCommissioner
4
+ module Seats
5
+ # Swaps the seats assigned to two tickets, or moves one ticket onto an empty seat -- the admin
6
+ # picks two blocks, which code path runs is derived here from each block's live
7
+ # ReservedBlock status, never chosen by the caller.
8
+ #
9
+ # success(blocks: [Block, Block])
10
+ # | failure(nil, :same_block | :block_not_found | :not_a_seat | :block_not_assigned
11
+ # | :different_seat_layout | :different_variant
12
+ # | :seat_locked | :seat_on_hold | :nothing_to_swap
13
+ # | :guest_checked_in | :seat_no_longer_reserved
14
+ # | :target_seat_no_longer_available | :lock_conflict)
15
+ # | failure({ guest_name:, messages: [...] }, :guest_invalid)
16
+ #
17
+ # IMPORTANT: `cm_reserved_blocks` has a blanket unique index on (block_id, inventory_item_id) --
18
+ # a block has exactly one row per inventory item for its whole lifetime, reused across its
19
+ # statuses (the same reason ReservedBlocks::Hold uses find_or_initialize_by(inventory_item:,
20
+ # block:) rather than creating a fresh row each time). That means `block_id` is each row's
21
+ # stable identity, never something this service can repoint -- what actually moves between seats
22
+ # is the *guest*.
23
+ #
24
+ # Swap (both blocks carry a `reserved` row): both existing rows are locked (one `SELECT ... FOR
25
+ # UPDATE ... ORDER BY id`, ascending, so two concurrent swaps touching an overlapping pair always
26
+ # request row locks in the same order and cannot deadlock), re-verified still reserved and not
27
+ # checked in, then their `guest_id`s are exchanged. Neither row's `block_id` ever changes, so
28
+ # neither can collide with the unique index; each produces its own `update`-type PaperTrail
29
+ # version.
30
+ #
31
+ # Move (exactly one block carries a `reserved` row, the other has none or only a `canceled` one):
32
+ # the ticket's existing row is locked, and the target seat's row is found-or-initialized by
33
+ # (block: target_block, inventory_item_id: <the ticket's own inventory item>) -- reusing a
34
+ # `canceled` row there if one exists, same as Hold would. No row to lock at a target that may not
35
+ # exist yet, so a Postgres advisory lock closes the race window instead (same shape as
36
+ # InventoryHolds::Acquire's with_advisory_lock!, keyed by the sorted block-id pair rather than an
37
+ # order id); inside it, the target's row is re-read fresh and re-checked for availability
38
+ # immediately before writing, closing the TOCTOU gap between the admin's view and this write. The
39
+ # source row is left behind as `canceled`, not destroyed, so its guest_id and history survive as
40
+ # the record of where the ticket used to sit; the target row's own `block_id` never changes
41
+ # either -- only its status/guest/gender move onto it.
42
+ class SwapSeats
43
+ prepend ::Spree::ServiceModule::Base
44
+ extend SpreeCmCommissioner::ServiceModuleThrowable
45
+
46
+ def call(block_a_id:, block_b_id:, actor: nil)
47
+ return failure(nil, :same_block) if block_a_id == block_b_id
48
+
49
+ block_a = SpreeCmCommissioner::Block.find_by(id: block_a_id)
50
+ block_b = SpreeCmCommissioner::Block.find_by(id: block_b_id)
51
+ return failure(nil, :block_not_found) if block_a.nil? || block_b.nil?
52
+
53
+ reject_reason = blocks_ineligibility_reason(block_a, block_b)
54
+ return failure(nil, reject_reason) if reject_reason
55
+
56
+ outcome, log_data = dispatch(block_a, block_b, actor)
57
+ return failure(nil, outcome) unless outcome == :ok
58
+
59
+ CmAppLogger.log(label: "#{self.class.name}#call success", data: log_data)
60
+ success(blocks: [block_a.reload, block_b.reload])
61
+ rescue StandardError => e
62
+ error = { error_type: e.class.name.demodulize, block_a_id: block_a_id, block_b_id: block_b_id, message: e.message }
63
+ CmAppLogger.error(label: "#{self.class.name}#call failed", data: error)
64
+ return guest_invalid_failure(e.record) if guest_invalid?(e)
65
+
66
+ failure(nil, e.message)
67
+ end
68
+
69
+ private
70
+
71
+ # move_guest! runs Guest's own validations, which can fail for reasons unrelated to seats (a
72
+ # guest saved before a validation was added, a seat whose variant no longer matches the ticket).
73
+ # The whole swap has already rolled back by here; this only names the guest and what to fix.
74
+ def guest_invalid?(error)
75
+ error.is_a?(ActiveRecord::RecordInvalid) && error.record.is_a?(SpreeCmCommissioner::Guest)
76
+ end
77
+
78
+ def guest_invalid_failure(guest)
79
+ failure({ guest_name: guest.full_name.presence, messages: guest.errors.full_messages }, :guest_invalid)
80
+ end
81
+
82
+ # Everything that can be decided from the two blocks alone, before either's live reservation
83
+ # status is even read.
84
+ def blocks_ineligibility_reason(block_a, block_b)
85
+ return :not_a_seat unless block_a.seatable? && block_b.seatable?
86
+ return :block_not_assigned if block_a.variant_id.nil? || block_b.variant_id.nil?
87
+ return :different_seat_layout if block_a.seat_layout_id != block_b.seat_layout_id
88
+ return :different_variant if block_a.variant_id != block_b.variant_id
89
+
90
+ nil
91
+ end
92
+
93
+ # Derives which of Swap/Move applies (or that neither does) from each block's live status, and
94
+ # runs it.
95
+ def dispatch(block_a, block_b, actor)
96
+ reserved_a = block_a.current_reserved_block
97
+ reserved_b = block_b.current_reserved_block
98
+ status_a = status_of(reserved_a)
99
+ status_b = status_of(reserved_b)
100
+
101
+ reject_reason = statuses_ineligibility_reason(status_a, status_b)
102
+ return [reject_reason, nil] if reject_reason
103
+ return swap!(reserved_a, reserved_b, actor) if status_a == :reserved && status_b == :reserved
104
+ return move!(reserved_a, block_b, actor) if status_a == :reserved
105
+
106
+ move!(reserved_b, block_a, actor)
107
+ end
108
+
109
+ def status_of(reserved_block)
110
+ reserved_block&.status&.to_sym || :available
111
+ end
112
+
113
+ # A seat reads "available" to #current_reserved_block whether it was never touched or only has
114
+ # a `canceled` row -- both are valid swap/move targets. `locked`/`on_hold` never are, on either
115
+ # side, and two `available` sides mean neither block carries a ticket to act on.
116
+ def statuses_ineligibility_reason(status_a, status_b)
117
+ return :seat_locked if status_a == :locked || status_b == :locked
118
+ return :seat_on_hold if status_a == :on_hold || status_b == :on_hold
119
+ return :nothing_to_swap if status_a == :available && status_b == :available
120
+
121
+ nil
122
+ end
123
+
124
+ def checked_in?(reserved_block)
125
+ guest = reserved_block&.guest
126
+ guest.present? && guest.check_ins.exists?
127
+ end
128
+
129
+ # Exchanges guest_id (and the other guest-carried fields) between the two rows -- block_id
130
+ # stays put on each, so this can never collide with the (block_id, inventory_item_id) unique
131
+ # index. Runs inside a single transaction with both rows locked by one ordered query, so two
132
+ # concurrent swaps touching an overlapping pair always request Postgres row locks in the same
133
+ # order and cannot deadlock each other.
134
+ def swap!(reserved_a, reserved_b, actor)
135
+ ordered_ids = [reserved_a.id, reserved_b.id].sort
136
+ outcome = nil
137
+ log_data = nil
138
+
139
+ SpreeCmCommissioner::ReservedBlock.transaction(requires_new: true) do
140
+ rows = SpreeCmCommissioner::ReservedBlock.lock.where(id: ordered_ids).order(:id).to_a
141
+
142
+ if rows.size != 2 || rows.any? { |row| !row.reserved? }
143
+ outcome = :seat_no_longer_reserved
144
+ raise ActiveRecord::Rollback
145
+ elsif rows.any? { |row| checked_in?(row) }
146
+ outcome = :guest_checked_in
147
+ raise ActiveRecord::Rollback
148
+ else
149
+ row_a = rows.find { |row| row.id == reserved_a.id }
150
+ row_b = rows.find { |row| row.id == reserved_b.id }
151
+ carry_a = guest_payload(row_a)
152
+ carry_b = guest_payload(row_b)
153
+
154
+ row_a.update!(**carry_b, updated_by: actor)
155
+ row_b.update!(**carry_a, updated_by: actor)
156
+ move_guest!(carry_a[:guest_id], row_b.block_id)
157
+ move_guest!(carry_b[:guest_id], row_a.block_id)
158
+
159
+ outcome = :ok
160
+ log_data = { mode: :swap, reserved_block_ids: ordered_ids, swapped_guest_ids: [carry_a[:guest_id], carry_b[:guest_id]],
161
+ actor_id: actor&.id
162
+ }
163
+ end
164
+ end
165
+
166
+ [outcome, log_data]
167
+ end
168
+
169
+ def move!(reserved, target_block, actor)
170
+ return [:guest_checked_in, nil] if checked_in?(reserved)
171
+
172
+ with_advisory_lock(swap_lock_key(reserved.block_id, target_block.id)) { perform_move!(reserved, target_block, actor) }
173
+ end
174
+
175
+ def perform_move!(reserved, target_block, actor)
176
+ outcome = nil
177
+ log_data = nil
178
+
179
+ SpreeCmCommissioner::ReservedBlock.transaction(requires_new: true) do
180
+ source_row = SpreeCmCommissioner::ReservedBlock.lock.find_by(id: reserved.id)
181
+
182
+ if source_row.nil? || !source_row.reserved?
183
+ outcome = :seat_no_longer_reserved
184
+ raise ActiveRecord::Rollback
185
+ elsif checked_in?(source_row)
186
+ outcome = :guest_checked_in
187
+ raise ActiveRecord::Rollback
188
+ else
189
+ # find_or_initialize_by, same as ReservedBlocks::Hold -- reuses a `canceled` row at this
190
+ # (block, inventory_item) if one exists, instead of ever creating a second row for the
191
+ # same pair. `.lock` is a no-op on a row that doesn't exist yet; it only matters when one
192
+ # does, which is exactly the case this re-check needs to be safe against a concurrent
193
+ # writer.
194
+ target_row = SpreeCmCommissioner::ReservedBlock.lock.find_or_initialize_by(block: target_block,
195
+ inventory_item_id: source_row.inventory_item_id
196
+ )
197
+
198
+ # Re-read fresh under the lock, not off any memoized association -- this is what closes
199
+ # the TOCTOU gap between the admin's view and this write.
200
+ if target_row.persisted? && target_row.unavailable?
201
+ outcome = :target_seat_no_longer_available
202
+ raise ActiveRecord::Rollback
203
+ else
204
+ target_row.assign_attributes(**guest_payload(source_row), updated_by: actor)
205
+ target_row.save!
206
+
207
+ source_row.update!(status: :canceled, expired_at: nil, updated_by: actor)
208
+ move_guest!(source_row.guest_id, target_block.id)
209
+
210
+ outcome = :ok
211
+ log_data = {
212
+ mode: :move, from_reserved_block_id: source_row.id, to_reserved_block_id: target_row.id,
213
+ from_block_id: source_row.block_id, to_block_id: target_block.id, actor_id: actor&.id
214
+ }
215
+ end
216
+ end
217
+ end
218
+
219
+ [outcome, log_data]
220
+ end
221
+
222
+ # The ReservedBlock row is what the seat map reads, but tickets, the guest email and the Telegram
223
+ # order message read Guest#block_id / #seat_number, so both have to move together. Plain
224
+ # update!, same as Guests::UpdateSeat: Guest's own callbacks reassign
225
+ # seat_number and recompute the order's preload_block_ids from all its guests, so a swap between
226
+ # two guests of the same order still ends with the right set once both have moved.
227
+ def move_guest!(guest_id, block_id)
228
+ guest = SpreeCmCommissioner::Guest.find_by(id: guest_id)
229
+ guest.update!(block_id: block_id) if guest && guest.block_id != block_id
230
+ end
231
+
232
+ # Everything that belongs to the guest's booking, not to the row's (block, inventory_item)
233
+ # identity -- what a swap exchanges and a move carries onto the target row.
234
+ def guest_payload(reserved_block)
235
+ {
236
+ status: :reserved,
237
+ guest_id: reserved_block.guest_id,
238
+ gender: reserved_block.gender,
239
+ expired_at: nil,
240
+ payment_locked_at: reserved_block.payment_locked_at,
241
+ inventory_date: reserved_block.inventory_date
242
+ }
243
+ end
244
+
245
+ def swap_lock_key(block_id_a, block_id_b)
246
+ "spree_cm_commissioner:seats_swap:#{[block_id_a, block_id_b].sort.join('-')}"
247
+ end
248
+
249
+ # Same pg_try_advisory_lock/pg_advisory_unlock shape as InventoryHolds::Acquire, keyed by the
250
+ # block pair instead of an order id -- a seat has no row of its own to lock until the move
251
+ # creates (or revives) one, so this is what stands in for a row lock here.
252
+ def with_advisory_lock(key)
253
+ lock_id = Zlib.crc32(key)
254
+ locked = [true, 't'].include?(ActiveRecord::Base.connection.select_value("SELECT pg_try_advisory_lock(#{lock_id})"))
255
+ return [:lock_conflict, nil] unless locked
256
+
257
+ begin
258
+ yield
259
+ ensure
260
+ ActiveRecord::Base.connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -2,9 +2,12 @@ module SpreeCmCommissioner
2
2
  module Seats
3
3
  # Returns operator-locked seats to sale — the inverse of LockBlocks.
4
4
  #
5
- # Deletes the locked row and gives the seat back to quantity_available. Rows that are no longer
6
- # `locked` — a customer's booking, or a lock already released — are skipped and never resurrected, so
7
- # a stale unlock request cannot destroy a real reservation.
5
+ # Cancels the locked row and gives the seat back to quantity_available. The row is kept rather than
6
+ # destroyed so the seat's PaperTrail history stays reachable from its block (Seats::BlockHistory) —
7
+ # a destroyed row's versions are keyed only by its old id. Hold/Reserve/LockBlocks all
8
+ # find_or_initialize_by (block, inventory_item), so the next booking or lock reuses this row. Rows
9
+ # that are no longer `locked` — a customer's booking, or a lock already released — are skipped, so a
10
+ # stale unlock request cannot touch a real reservation.
8
11
  #
9
12
  # success(applied: [ReservedBlock, ...], skipped: [{ date:, block_label:, reason: }, ...])
10
13
  class UnlockBlocks
@@ -44,17 +47,13 @@ module SpreeCmCommissioner
44
47
  inventory_item_id: item.id, block_id: block.id
45
48
  )
46
49
 
47
- # Absent, or belongs to a customer now, or already released — not ours to remove.
50
+ # Absent, or belongs to a customer now, or already released — not ours to release.
48
51
  return :not_locked if reserved_block.nil? || !reserved_block.locked?
49
52
 
50
- # Stamp the actor before destroying so the paper_trail destroy version records who released it.
51
- # update_columns, not update!: the row is about to be deleted, so validations and callbacks are
52
- # pointless, and PaperTrail serialises the in-memory attributes either way. (whodunnit is not
53
- # configured in this app, so this stamp is the only audit trail for an unlock.)
54
- # rubocop:disable Rails/SkipsModelValidations
55
- reserved_block.update_columns(updated_by_id: actor.id) if actor.present?
56
- # rubocop:enable Rails/SkipsModelValidations
57
- reserved_block.destroy!
53
+ # Reason/note are cleared so a later reuse of this row doesn't carry a stale lock reason; the
54
+ # lock itself stays readable in the versions. updated_by is what history attributes the
55
+ # release to — the async lock job runs with no whodunnit.
56
+ reserved_block.update!(status: :canceled, lock_reason: nil, lock_note: nil, updated_by: actor)
58
57
 
59
58
  # lock: false — we are already inside item.with_lock. warm_redis: false — the whole
60
59
  # grid was warmed once in each_block_date.
@@ -0,0 +1,99 @@
1
+ <%
2
+ i = @insights
3
+ hold, order, user = i[:hold], i[:order], i[:user]
4
+ q = SpreeCmCommissioner::InventoryHoldInsightsQuery
5
+ uh, ih, uo = i[:user_holds], i[:ip_holds], i[:user_orders]
6
+ verdicts = {
7
+ normal: ['success', 'Looks like a normal customer', 'No test, abandon or fraud signals found.'],
8
+ test: ['info', 'Probably a test order', 'Made by staff or a test account — exclude it from real sales numbers.'],
9
+ abandon: ['warning', 'Likely abandoned', 'This customer keeps holding seats without paying.'],
10
+ fraud: ['danger', 'Suspicious activity', 'Hold pattern looks automated or abusive — check the IP section below.']
11
+ }
12
+ tone, headline, explanation = verdicts[i[:verdict]]
13
+ %>
14
+
15
+ <div class="hi-verdict <%= tone %>">
16
+ <div class="hi-verdict-title"><%= headline %></div>
17
+ <div class="small"><%= explanation %></div>
18
+ <% reasons = i[:flags].values.flatten %>
19
+ <% if reasons.any? %>
20
+ <ul class="mb-0 mt-2 pl-3 small">
21
+ <% reasons.each do |reason| %><li><%= reason %></li><% end %>
22
+ </ul>
23
+ <% end %>
24
+ </div>
25
+
26
+ <%= render layout: 'section', locals: { title: 'This order', hint: 'Where the customer stopped in checkout.' } do %>
27
+ <dl class="hi-kv">
28
+ <dt>Order</dt><dd><%= order ? link_to(order.number, cart_admin_order_path(order)) : '—' %> · <%= order ? Spree::Money.new(order.total, currency: order.currency) : '—' %></dd>
29
+ <dt>Checkout step</dt><dd><%= order&.state&.humanize || '—' %></dd>
30
+ <dt>Payment</dt><dd><%= order&.payment_state&.humanize || 'Never reached payment' %></dd>
31
+ <dt>Hold</dt><dd><%= hold.status.humanize %><%= " — #{hold.release_reason.humanize.downcase}" if hold.release_reason %> · <%= hold.total_quantity_on_hold.to_i %> seats/items</dd>
32
+ </dl>
33
+ <% end %>
34
+
35
+ <%= render layout: 'section', locals: { title: 'Customer history', hint: 'Many paid orders = trusted customer. Zero paid orders = new, stuck, or not a real buyer.' } do %>
36
+ <div class="mb-2 small">
37
+ <%= user ? link_to(user.email, admin_user_path(user)) : (order&.email.presence || 'Guest checkout') %>
38
+ <% i[:roles].each do |role| %><span class="badge badge-dark ml-1"><%= role %></span><% end %>
39
+ </div>
40
+ <div class="hi-stats">
41
+ <%= render 'stat', value: uo[:completed], label: 'Paid orders', tone: ('success' if uo[:completed].positive?) %>
42
+ <%= render 'stat', value: uo[:incomplete], label: 'Unfinished orders', tone: ('warning' if uo[:completed].zero? && uo[:incomplete].positive?) %>
43
+ <%= render 'stat', value: uo[:last_completed_at] ? time_ago_in_words(uo[:last_completed_at]) + ' ago' : 'Never', label: 'Last paid' %>
44
+ </div>
45
+ <% end %>
46
+
47
+ <%= render layout: 'section', locals: { title: 'How fast is this user holding seats?', hint: 'Counted up to the moment this hold was made. A real person rarely makes more than 1–2 holds in 5 minutes.' } do %>
48
+ <div class="hi-stats">
49
+ <%= render 'stat', value: uh[:in_5m], label: 'Holds in 5 min' %>
50
+ <%= render 'stat', value: uh[:concurrent], label: 'Open right now', tone: ('danger' if uh[:concurrent] >= q::USER_CONCURRENT_HOLDS) %>
51
+ <%= render 'stat', value: uh[:abandoned], label: 'Expired unpaid', tone: ('warning' if uh[:abandoned] >= q::USER_ABANDONED_NO_CONVERT && uh[:converted].zero?) %>
52
+ <%= render 'stat', value: "#{uh[:converted]} / #{uh[:total]}", label: 'Paid / all holds' %>
53
+ </div>
54
+ <% end %>
55
+
56
+ <%= render layout: 'section', locals: { title: "Same internet address (IP #{hold.ip_address.presence || 'unknown'})", hint: 'Everyone connecting from this IP. Many accounts or many holds in a few minutes from one IP usually means a bot or one person with several accounts. A few users can also be shared office/café WiFi.' } do %>
57
+ <div class="hi-stats mb-3">
58
+ <%= render 'stat', value: ih[:in_3m], label: 'Holds in 3 min', tone: ('danger' if ih[:in_3m] >= q::IP_BURST_5M) %>
59
+ <%= render 'stat', value: ih[:in_5m], label: 'Holds in 5 min', tone: ('danger' if ih[:in_5m] >= q::IP_BURST_5M) %>
60
+ <%= render 'stat', value: ih[:distinct_users_24h], label: 'Accounts in 24h', tone: ('danger' if ih[:distinct_users_24h] >= q::IP_DISTINCT_USERS_24H) %>
61
+ <%= render 'stat', value: "#{ih[:converted]} / #{ih[:total]}", label: 'Paid / all holds' %>
62
+ </div>
63
+ <% if i[:ip_recent_holds].any? %>
64
+ <div class="small text-muted mb-1">Latest other holds from this IP</div>
65
+ <table class="table table-sm small mb-1">
66
+ <thead class="text-muted"><tr><th>Customer</th><th>Order</th><th>Order status</th><th>Hold</th><th></th></tr></thead>
67
+ <% i[:ip_recent_holds].each do |h| %>
68
+ <tr>
69
+ <td class="text-truncate" style="max-width:160px;"><%= h[:email] || 'Guest' %></td>
70
+ <td><%= h[:order_number] || '—' %></td>
71
+ <td class="no-wrap"><%= render 'order_status', state: h[:order_state], payment_state: h[:payment_state] %></td>
72
+ <td><span class="badge badge-light"><%= h[:status].to_s.humanize %></span></td>
73
+ <td class="text-muted no-wrap text-right"><%= time_ago_in_words(h[:created_at]) %> ago</td>
74
+ </tr>
75
+ <% end %>
76
+ </table>
77
+ <%= link_to 'See all holds from this IP →', admin_inventory_holds_path(q: { ip_address_eq: hold.ip_address }), class: 'small' %>
78
+ <% else %>
79
+ <div class="small text-muted">No other holds from this IP.</div>
80
+ <% end %>
81
+ <% end %>
82
+
83
+ <%= render layout: 'section', locals: { title: "Customer's recent orders", hint: 'Last 10 orders, newest first. A long list of unfinished carts with no paid order is a warning sign.' } do %>
84
+ <% if i[:recent_orders].any? %>
85
+ <table class="table table-sm small mb-0">
86
+ <thead class="text-muted"><tr><th>Order</th><th>Order status</th><th class="text-right">Total</th><th></th></tr></thead>
87
+ <% i[:recent_orders].each do |o| %>
88
+ <tr>
89
+ <td><%= o[:number] %></td>
90
+ <td class="no-wrap"><%= render 'order_status', state: o[:state], payment_state: o[:payment_state] %></td>
91
+ <td class="text-right"><%= Spree::Money.new(o[:total], currency: o[:currency]) %></td>
92
+ <td class="text-muted no-wrap text-right"><%= time_ago_in_words(o[:created_at]) %> ago</td>
93
+ </tr>
94
+ <% end %>
95
+ </table>
96
+ <% else %>
97
+ <div class="small text-muted">No orders.</div>
98
+ <% end %>
99
+ <% end %>
@@ -0,0 +1,83 @@
1
+ <%# Hidden until an Insights / Overall insights button is clicked; the body is fetched from its data-url. %>
2
+ <div id="hold_insights_backdrop" class="hold-insights-backdrop"></div>
3
+ <div id="hold_insights_drawer" class="hold-insights-drawer" role="dialog" aria-labelledby="hold_insights_title">
4
+ <div class="hold-insights-header">
5
+ <h5 class="mb-0" id="hold_insights_title">Hold insights</h5>
6
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-dismiss="drawer" aria-label="Close">&times;</button>
7
+ </div>
8
+ <div id="hold_insights_body" class="hold-insights-body"></div>
9
+ </div>
10
+
11
+ <style>
12
+ .hold-insights-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.4); opacity: 0; visibility: hidden; transition: opacity .2s ease; z-index: 1040; }
13
+ .hold-insights-backdrop.open { opacity: 1; visibility: visible; }
14
+ .hold-insights-drawer { position: fixed; top: 0; right: 0; height: 100%; width: 600px; max-width: 92vw; background: #fff; box-shadow: -2px 0 12px rgba(0,0,0,.2); transform: translateX(100%); transition: transform .25s ease; z-index: 1050; display: flex; flex-direction: column; }
15
+ .hold-insights-drawer.open { transform: translateX(0); }
16
+ .hold-insights-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid #e9ecef; }
17
+ .hold-insights-body { padding: 1.25rem; overflow-y: auto; flex: 1; }
18
+ .hold-insights-body { background: #f8f9fa; }
19
+ .hi-section { background: #fff; border: 1px solid #e9ecef; border-radius: .5rem; padding: .875rem 1rem; margin-bottom: 1rem; }
20
+ .hi-section-title { font-weight: 600; }
21
+ .hi-section-hint { font-size: .8rem; color: #6c757d; margin-bottom: .75rem; }
22
+ .hi-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(115px, 1fr)); gap: .5rem; }
23
+ .hi-stat { background: #f1f3f5; border-radius: .375rem; padding: .5rem .75rem; }
24
+ .hi-stat-value { font-size: 1.25rem; font-weight: 600; line-height: 1.2; }
25
+ .hi-stat-label { font-size: .75rem; color: #6c757d; }
26
+ .hi-stat.success { background: #e6f4ea; } .hi-stat.success .hi-stat-value { color: #1e7e34; }
27
+ .hi-stat.warning { background: #fff4e0; } .hi-stat.warning .hi-stat-value { color: #b86e00; }
28
+ .hi-stat.danger { background: #fdecea; } .hi-stat.danger .hi-stat-value { color: #c0392b; }
29
+ .hi-verdict { border-radius: .5rem; padding: .875rem 1rem; margin-bottom: 1rem; border-left: 4px solid; }
30
+ .hi-verdict-title { font-size: 1.05rem; font-weight: 600; }
31
+ .hi-verdict.success { background: #e6f4ea; border-color: #28a745; }
32
+ .hi-verdict.info { background: #e7f3fb; border-color: #17a2b8; }
33
+ .hi-verdict.warning { background: #fff4e0; border-color: #f0a500; }
34
+ .hi-verdict.danger { background: #fdecea; border-color: #dc3545; }
35
+ .hi-kv { display: grid; grid-template-columns: 110px 1fr; gap: .25rem .75rem; margin: 0; font-size: .875rem; }
36
+ .hi-kv dt { color: #6c757d; font-weight: normal; }
37
+ .hi-kv dd { margin: 0; }
38
+ .hi-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
39
+ </style>
40
+
41
+ <script>
42
+ (function() {
43
+ var drawer = document.getElementById('hold_insights_drawer');
44
+ var backdrop = document.getElementById('hold_insights_backdrop');
45
+ var body = document.getElementById('hold_insights_body');
46
+ var title = document.getElementById('hold_insights_title');
47
+ var requestId = 0;
48
+
49
+ function close() {
50
+ drawer.classList.remove('open');
51
+ backdrop.classList.remove('open');
52
+ document.body.style.removeProperty('overflow');
53
+ }
54
+
55
+ backdrop.addEventListener('click', close);
56
+ drawer.querySelector('[data-dismiss="drawer"]').addEventListener('click', close);
57
+
58
+ document.querySelectorAll('.js-hold-insights').forEach(function(button) {
59
+ button.addEventListener('click', function() {
60
+ var current = ++requestId;
61
+ title.textContent = button.dataset.title;
62
+ body.innerHTML = '<p class="text-muted">Loading…</p>';
63
+ drawer.classList.add('open');
64
+ backdrop.classList.add('open');
65
+ document.body.style.overflow = 'hidden';
66
+
67
+ fetch(button.dataset.url, { credentials: 'same-origin', headers: { 'Accept': 'text/html' } })
68
+ .then(function(response) {
69
+ if (!response.ok) throw new Error('Request failed (' + response.status + ')');
70
+ return response.text();
71
+ })
72
+ .then(function(html) { if (current === requestId) body.innerHTML = html; })
73
+ .catch(function(error) {
74
+ if (current !== requestId) return;
75
+ var message = document.createElement('p');
76
+ message.className = 'text-danger';
77
+ message.textContent = error.message;
78
+ body.replaceChildren(message);
79
+ });
80
+ });
81
+ });
82
+ })();
83
+ </script>
@@ -0,0 +1,9 @@
1
+ <%# Checkout step + payment state, shared by the holds table and the insights drawer. %>
2
+ <% if state %>
3
+ <span class="badge <%= state == 'complete' ? 'badge-success' : 'badge-light' %>"><%= state.humanize %></span>
4
+ <% if payment_state %>
5
+ <span class="badge <%= { 'paid' => 'badge-success', 'failed' => 'badge-danger', 'void' => 'badge-secondary' }[payment_state] || 'badge-warning' %>"><%= payment_state.humanize %></span>
6
+ <% end %>
7
+ <% else %>
8
+ <span class="text-muted">—</span>
9
+ <% end %>
@@ -0,0 +1,109 @@
1
+ <%
2
+ o = @overview
3
+ f = o[:funnel]
4
+ pct = ->(n) { f[:total].zero? ? 0 : (100.0 * n / f[:total]).round(1) }
5
+ segments = [
6
+ [:converted, 'Paid', 'bg-success'],
7
+ [:open, 'Still in checkout', 'bg-primary'],
8
+ [:cart_abandoned, 'Left at cart', 'bg-warning'],
9
+ [:payment_abandoned, 'Left at payment', 'bg-danger'],
10
+ [:user_canceled, 'Canceled', 'bg-secondary'],
11
+ [:cart_changed, 'Changed cart', 'bg-info']
12
+ ]
13
+ %>
14
+
15
+ <p class="small text-muted">All holds matching the table filters since <strong><%= order_time(@overview_from) %></strong>.</p>
16
+
17
+ <%= render layout: 'section', locals: { title: 'How did holds end?', hint: '"Left at payment" jumping suddenly usually means a payment gateway problem, not customers.' } do %>
18
+ <div class="hi-stats mb-3">
19
+ <%= render 'stat', value: f[:total], label: 'Holds' %>
20
+ <%= render 'stat', value: "#{f[:conversion_rate]}%", label: 'Paid (conversion)', tone: 'success' %>
21
+ <%= render 'stat', value: "#{pct.call(f[:cart_abandoned])}%", label: 'Left at cart', tone: 'warning' %>
22
+ <%= render 'stat', value: "#{pct.call(f[:payment_abandoned])}%", label: 'Left at payment', tone: 'danger' %>
23
+ </div>
24
+ <% if f[:total].positive? %>
25
+ <div class="progress mb-2" style="height: 10px;">
26
+ <% segments.each do |key, _, css| %>
27
+ <div class="progress-bar <%= css %>" style="width: <%= pct.call(f[key]) %>%"></div>
28
+ <% end %>
29
+ </div>
30
+ <div class="small d-flex flex-wrap">
31
+ <% segments.each do |key, label, css| %>
32
+ <span class="mr-3 no-wrap"><span class="hi-dot <%= css %>"></span><%= label %>: <strong><%= f[key] %></strong></span>
33
+ <% end %>
34
+ </div>
35
+ <% end %>
36
+ <% end %>
37
+
38
+ <%= render layout: 'section', locals: { title: 'Suspicious bursts', hint: 'Time is cut into 3- and 5-minute blocks. Each row is one IP making many holds inside one block — a human can do 1–2, a bot does 10+.' } do %>
39
+ <% o[:bursts].each do |bin, rows| %>
40
+ <div class="small font-weight-bold mb-1">Within <%= bin.sub('m', ' minutes') %></div>
41
+ <% if rows.any? %>
42
+ <table class="table table-sm small mb-3">
43
+ <thead class="text-muted"><tr><th>IP</th><th>When</th><th class="text-right">Holds</th><th class="text-right">Accounts</th></tr></thead>
44
+ <% rows.each do |row| %>
45
+ <tr class="<%= 'table-danger' if row[:holds] >= SpreeCmCommissioner::InventoryHoldInsightsQuery::IP_BURST_5M %>">
46
+ <td><%= link_to row[:ip_address], admin_inventory_holds_path(q: { ip_address_eq: row[:ip_address] }) %></td>
47
+ <td class="no-wrap"><%= order_time(row[:started_at]) %></td>
48
+ <td class="text-right"><%= row[:holds] %></td>
49
+ <td class="text-right"><%= row[:users] %></td>
50
+ </tr>
51
+ <% end %>
52
+ </table>
53
+ <% else %>
54
+ <div class="small text-muted mb-3">No IP made more than 1 hold in any <%= bin.sub('m', '-minute') %> block.</div>
55
+ <% end %>
56
+ <% end %>
57
+ <% end %>
58
+
59
+ <%= render layout: 'section', locals: { title: 'Busiest IPs', hint: 'IPs with the most holds. Red = more than 2 accounts on one IP (shared WiFi, or one person with many accounts). Many holds but 0 paid = likely blocking seats.' } do %>
60
+ <% if o[:top_ips].any? %>
61
+ <table class="table table-sm small mb-0">
62
+ <thead class="text-muted"><tr><th>IP</th><th class="text-right">Holds</th><th class="text-right">Accounts</th><th class="text-right">Paid</th></tr></thead>
63
+ <% o[:top_ips].each do |row| %>
64
+ <tr class="<%= 'table-danger' if row[:users] > 2 %>">
65
+ <td><%= link_to row[:ip_address], admin_inventory_holds_path(q: { ip_address_eq: row[:ip_address] }) %></td>
66
+ <td class="text-right"><%= row[:holds] %></td>
67
+ <td class="text-right"><%= row[:users] %></td>
68
+ <td class="text-right"><%= row[:converted] %></td>
69
+ </tr>
70
+ <% end %>
71
+ </table>
72
+ <% else %>
73
+ <div class="small text-muted">No holds with an IP.</div>
74
+ <% end %>
75
+ <% end %>
76
+
77
+ <%= render layout: 'section', locals: { title: 'Customers who hold but never pay', hint: 'Let holds expire and paid 0 times in this period. These block seats from real buyers.' } do %>
78
+ <% if o[:abandoners].any? %>
79
+ <table class="table table-sm small mb-0">
80
+ <thead class="text-muted"><tr><th>Customer</th><th class="text-right">Expired unpaid</th><th class="text-right">All holds</th></tr></thead>
81
+ <% o[:abandoners].each do |row| %>
82
+ <tr>
83
+ <td class="text-truncate" style="max-width:260px;"><%= link_to row[:email], admin_inventory_holds_path(q: { user_id_eq: row[:user_id] }) %></td>
84
+ <td class="text-right"><%= row[:abandoned] %></td>
85
+ <td class="text-right"><%= row[:holds] %></td>
86
+ </tr>
87
+ <% end %>
88
+ </table>
89
+ <% else %>
90
+ <div class="small text-muted">Nobody — every customer with expired holds also paid at least once.</div>
91
+ <% end %>
92
+ <% end %>
93
+
94
+ <%= render layout: 'section', locals: { title: 'Test & staff accounts', hint: 'Holds by staff (admin/vendor role) or test-looking emails. Not real customers — ignore them in sales numbers.' } do %>
95
+ <% if o[:test_accounts].any? %>
96
+ <table class="table table-sm small mb-0">
97
+ <thead class="text-muted"><tr><th>Account</th><th class="text-right">Holds</th><th></th></tr></thead>
98
+ <% o[:test_accounts].each do |row| %>
99
+ <tr>
100
+ <td class="text-truncate" style="max-width:260px;"><%= link_to row[:email], admin_inventory_holds_path(q: { user_id_eq: row[:user_id] }) %></td>
101
+ <td class="text-right"><%= row[:holds] %></td>
102
+ <td class="text-right"><span class="badge <%= row[:staff] ? 'badge-dark' : 'badge-info' %>"><%= row[:staff] ? 'Staff' : 'Test email' %></span></td>
103
+ </tr>
104
+ <% end %>
105
+ </table>
106
+ <% else %>
107
+ <div class="small text-muted">None in this period.</div>
108
+ <% end %>
109
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <div class="hi-section">
2
+ <div class="hi-section-title"><%= title %></div>
3
+ <div class="hi-section-hint"><%= hint %></div>
4
+ <%= yield %>
5
+ </div>
@@ -0,0 +1,4 @@
1
+ <div class="hi-stat <%= local_assigns[:tone] %>">
2
+ <div class="hi-stat-value"><%= value %></div>
3
+ <div class="hi-stat-label"><%= label %></div>
4
+ </div>