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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5538af5130244a626ceca3f818410469c93ea7975f5cbc51b212b7536361b8e
4
- data.tar.gz: 4e272e66836c76b1296aad6a65488cd17e3ef9517a4a8112594047875de4a185
3
+ metadata.gz: 32502c54cc175c2fc2c4d6c86d97385fe1c02192ddfa240e6ac28b043fc923c3
4
+ data.tar.gz: be2a8f37d0f078acd74e6e16155928065a2915b868525c01ae01232dcc8d9e39
5
5
  SHA512:
6
- metadata.gz: 80bc600859e7bdf199590d0a5e7982b9b03bbef4ddc9b19e03fbbbe6be76ceca11817687b1d358db20be3b9b7665154d9a05db59412e690448982cf70f543e4d
7
- data.tar.gz: 646dea4a475811f6452137087b603d3bb36b6832bd6550339713b84a82f7ee0c71054b81632aebd4ddd041eef14ceca479402e679b0ab49fbf40d87f0e69ec15
6
+ metadata.gz: eddc350347e96f820443be5e8e9524cab22623c8c1f1cd16241a86d5d1e851e206e264328d1c9734b427243682f8bffcdacb1215fb0647b472071d2c5484429b
7
+ data.tar.gz: 767d8e127f3cd3ddb498e0fe3237571acb753b6481480531d4094500b4d58e5f60d360184475f951c75217d6c2eb91b2b1e3862fd83b2ba140dcc35de81e880e
data/.env.example CHANGED
@@ -94,3 +94,6 @@ PRE_INVENTORY_DAYS_TRANSIT=180 # How many days ahead inventory items are pre-ge
94
94
  PRE_INVENTORY_DAYS_ACCOMMODATION=365 # How many days ahead inventory items are pre-generated for accommodation variants
95
95
  PRE_INVENTORY_DAYS_SERVICE=180 # How many days ahead inventory items are pre-generated for service variants
96
96
  PAYMENT_BASE_URL=http://localhost:4000
97
+
98
+ # See: app/services/spree_cm_commissioner/integrations/call_limiter.rb
99
+ BOOKMEBUS_MAX_CALLS_PER_MINUTE=60 # Most calls a minute BookMe+ sends to BookMeBus, shared by all vendors (checkout is never limited); lower it to protect their server, raise it only after agreeing a limit with them
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.15.2)
37
+ spree_cm_commissioner (2.15.3)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -886,7 +886,7 @@ GEM
886
886
  spree_backend (>= 4.3.0.rc1)
887
887
  spree_emails (>= 4.3.0.rc1)
888
888
  spree_extension
889
- spree_vpago (2.4.6)
889
+ spree_vpago (2.4.7)
890
890
  faraday
891
891
  google-cloud-firestore
892
892
  spree_api (>= 4.5)
@@ -4,12 +4,7 @@ module Spree
4
4
  def index
5
5
  authorize! :manage, SpreeCmCommissioner::InventoryHold
6
6
 
7
- q = params.fetch(:q, {})
8
- q = q.respond_to?(:to_unsafe_h) ? q.to_unsafe_h.deep_dup : q.deep_dup
9
- q['s'] ||= 'created_at desc'
10
- q['created_at_gt'] = parse_time_or_nil(q['created_at_gt'], :beginning_of_day) if q['created_at_gt'].present?
11
- q['created_at_lt'] = parse_time_or_nil(q['created_at_lt'], :end_of_day) if q['created_at_lt'].present?
12
- normalize_enum_filters!(q)
7
+ q = search_query
13
8
 
14
9
  @search = scope.ransack(q)
15
10
  result_scope = @search.result(distinct: true)
@@ -26,6 +21,27 @@ module Spree
26
21
  .reorder(nil).group(:status).count
27
22
  end
28
23
 
24
+ # Same filters as the table; without a start date it covers the last 24h so the GROUP BYs stay cheap.
25
+ def overview
26
+ authorize! :manage, SpreeCmCommissioner::InventoryHold
27
+
28
+ q = search_query
29
+ @overview_from = q['created_at_gt'].presence || 24.hours.ago
30
+ q['created_at_gt'] = @overview_from
31
+ @overview = SpreeCmCommissioner::InventoryHoldOverviewQuery.new(holds: scope.ransack(q).result).call
32
+
33
+ render partial: 'overview', layout: false
34
+ end
35
+
36
+ def insights
37
+ authorize! :manage, SpreeCmCommissioner::InventoryHold
38
+
39
+ hold = scope.includes(:user, order: :user).find(params[:id])
40
+ @insights = SpreeCmCommissioner::InventoryHoldInsightsQuery.new(hold: hold).call
41
+
42
+ render partial: 'insights', layout: false
43
+ end
44
+
29
45
  def release
30
46
  authorize! :manage, SpreeCmCommissioner::InventoryHold
31
47
 
@@ -49,6 +65,16 @@ module Spree
49
65
 
50
66
  private
51
67
 
68
+ def search_query
69
+ q = params.fetch(:q, {})
70
+ q = q.respond_to?(:to_unsafe_h) ? q.to_unsafe_h.deep_dup : q.deep_dup
71
+ q['s'] ||= 'created_at desc'
72
+ q['created_at_gt'] = parse_time_or_nil(q['created_at_gt'], :beginning_of_day) if q['created_at_gt'].present?
73
+ q['created_at_lt'] = parse_time_or_nil(q['created_at_lt'], :end_of_day) if q['created_at_lt'].present?
74
+ normalize_enum_filters!(q)
75
+ q
76
+ end
77
+
52
78
  # `status` and `release_reason` are integer-backed enums. Ransack casts a string
53
79
  # value for an integer column with String#to_i, so "payment_locked" becomes 0
54
80
  # (== :pending) and every label silently filters to the wrong status. Translate
@@ -0,0 +1,28 @@
1
+ module SpreeCmCommissioner::Integrations
2
+ # The partner answered 429 (or CallLimiter refused the call). Sync code stops at the first one instead of
3
+ # retrying. An ExternalClientError, so checkout handles it like any other failed call.
4
+ class RateLimitedError < ExternalClientError
5
+ DEFAULT_RETRY_AFTER = 60.seconds
6
+ MAX_RETRY_AFTER = 2.hours
7
+
8
+ attr_reader :retry_after
9
+
10
+ # @param retry_after [Integer, nil] seconds to wait (the Retry-After header)
11
+ def initialize(message, retry_after: nil)
12
+ super(message, 429)
13
+ @retry_after = (retry_after || DEFAULT_RETRY_AFTER).to_i.clamp(DEFAULT_RETRY_AFTER.to_i, MAX_RETRY_AFTER.to_i)
14
+ end
15
+
16
+ # Retry-After is a number of seconds or an HTTP date; anything else is nil.
17
+ def self.parse_retry_after(header)
18
+ value = header.to_s.strip
19
+ return nil if value.empty?
20
+ return value.to_i if value.match?(/\A\d+\z/)
21
+
22
+ seconds = (Time.httpdate(value) - Time.current).ceil
23
+ seconds.positive? ? seconds : nil
24
+ rescue ArgumentError
25
+ nil
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,70 @@
1
+ # Syncs the trips of one route for the next `sync_days` days. The daily full sync queues one per route, so a failing
2
+ # route only fails itself, a deploy redoes one route (seconds, not minutes), and a rerun skips routes done today.
3
+ class SpreeCmCommissioner::Integrations::BookMeBusV1::SyncRouteJob < SpreeCmCommissioner::ApplicationUniqueJob
4
+ queue_as :integration
5
+
6
+ MAX_ATTEMPTS = 3
7
+ RETRY_DELAY = 15.minutes
8
+ MIN_PAUSE = 1.minute # the call limit refills every minute
9
+
10
+ # @param options [Hash] :integration_id, :route_id, :attempt (1 for the first run, set by the retries)
11
+ def perform(options)
12
+ integration = SpreeCmCommissioner::Integration.find_by(id: options[:integration_id])
13
+ route = SpreeCmCommissioner::Route.find_by(id: options[:route_id])
14
+ return unless integration&.active? && route
15
+
16
+ # While BookMeBus is paused after a 429, don't call it: retry after the pause.
17
+ limiter = SpreeCmCommissioner::Integrations::CallLimiter.new(base_url: integration.base_url)
18
+ return retry_after_pause(options, limiter.paused_for) if limiter.paused?
19
+
20
+ sync_result = SpreeCmCommissioner::Integrations::Base::SyncResult.new
21
+ sync(integration, route, sync_result)
22
+
23
+ # Told to wait (our call limit or a 429): nothing wrong with the route, so no outcome is recorded.
24
+ return retry_after_pause(options, sync_result.to_h.dig(:error, :retry_after)) if sync_result.rate_limited?
25
+
26
+ record_outcome(integration, route, sync_result)
27
+ retry_later(options) unless sync_result.success?
28
+ end
29
+
30
+ private
31
+
32
+ def sync(integration, route, sync_result)
33
+ SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncTrips.new(
34
+ integration: integration, sync_result: sync_result, route: route
35
+ ).call(on_dates: integration.sync_dates)
36
+ rescue StandardError => e
37
+ sync_result.record_error(e)
38
+ end
39
+
40
+ # Kept on the route's mapping: `full_synced_on` (the next full sync skips it) or `last_error`.
41
+ def record_outcome(integration, route, sync_result)
42
+ mapping = SpreeCmCommissioner::IntegrationMapping.active_routes_of(integration).find_by(internal_id: route.id)
43
+ return unless mapping
44
+
45
+ outcome = sync_result.success? ? { 'full_synced_on' => Date.current.iso8601 } : { 'last_error' => sync_result.to_h.dig(:error, :message) }
46
+
47
+ # The incremental sync stamps the same column: the lock (which reloads first) keeps either from erasing the other's key.
48
+ mapping.with_lock do
49
+ mapping.update!(private_metadata: (mapping.private_metadata || {}).except('last_error', 'full_synced_on').merge(outcome))
50
+ end
51
+ end
52
+
53
+ # A real failure: tries again a few times today (15 min, then 30), then leaves it to the next full sync.
54
+ def retry_later(options)
55
+ attempt = (options[:attempt] || 1).to_i
56
+ return if attempt >= MAX_ATTEMPTS
57
+
58
+ self.class.set(wait: RETRY_DELAY * attempt).perform_later(options.merge(attempt: attempt + 1))
59
+ end
60
+
61
+ # Waits out the pause (at least a minute) and tries again as the same attempt: a large vendor queues all its routes
62
+ # at once, and the ones the call limit turns away must not use up their attempts just by waiting in line.
63
+ #
64
+ # `waits` changes the arguments on purpose: ApplicationUniqueJob drops a job whose arguments match one that is still
65
+ # running (`unique :until_executed`), and this job is still running while it queues its own retry. Without it the
66
+ # retry is dropped silently and the route is never synced.
67
+ def retry_after_pause(options, wait)
68
+ self.class.set(wait: [wait.to_i.seconds, MIN_PAUSE].max).perform_later(options.merge(waits: options[:waits].to_i + 1))
69
+ end
70
+ end
@@ -60,6 +60,22 @@ module SpreeCmCommissioner
60
60
  self.class.sellable?(block_type)
61
61
  end
62
62
 
63
+ # The ReservedBlock row currently making this seat unbookable (reserved, on_hold, or
64
+ # operator-locked and not expired) — nil when the seat is open for sale. Reads off the
65
+ # already-loaded all_reserved_blocks association rather than querying, so a caller that
66
+ # preloads it (e.g. the organizer seat status view, rendering every block on a layout) does
67
+ # not pay a query per block.
68
+ #
69
+ # Only meaningful for a block with a single relevant InventoryItem, e.g. an Event
70
+ # session-variant's undated item. A Transit block spans one InventoryItem per departure date, so
71
+ # all_reserved_blocks can hold several simultaneously-active rows (locked on one date, sold on
72
+ # another) and this returns an arbitrary one of them, not "the" status for any particular date.
73
+ # Do not call this for a date-scoped seat map — use Seats::LockState instead, which resolves
74
+ # status per (block, date).
75
+ def current_reserved_block
76
+ all_reserved_blocks.detect(&:unavailable?)
77
+ end
78
+
63
79
  def assign_layout_from_section
64
80
  self.seat_layout = seat_section.seat_layout
65
81
  end
@@ -6,6 +6,8 @@ module SpreeCmCommissioner
6
6
 
7
7
  enum status: { active: 0, archived: 1 }
8
8
 
9
+ scope :active_routes_of, -> (integration) { active.where(integration_id: integration.id, internal_type: 'SpreeCmCommissioner::Route') }
10
+
9
11
  # polymorphic (Spree::Taxon, Spree::Product, Spree::Variant, Spree::Vendor)
10
12
  belongs_to :internal, polymorphic: true, optional: false
11
13
  belongs_to :integration, class_name: 'SpreeCmCommissioner::Integration', inverse_of: :integration_mappings, optional: false
@@ -32,6 +34,12 @@ module SpreeCmCommissioner
32
34
  save!
33
35
  end
34
36
 
37
+ # True when `new_payload` differs from the stored one only in `ignoring` keys (at any depth), i.e. nothing the
38
+ # rest of the sync reads back has changed. Does not write anything.
39
+ def payload_unchanged?(new_payload, ignoring: [])
40
+ persisted? && active? && stable_payload(new_payload.as_json, ignoring) == stable_payload(external_payload.as_json, ignoring)
41
+ end
42
+
35
43
  # For polling, which confirms the same mappings every few seconds. last_synced_at is indexed, so each save rewrites
36
44
  # every index on this table; this skips the save while nothing a reader uses has changed. `ignoring` lists payload
37
45
  # keys that move on every poll (seats, for one), at any depth.
@@ -8,6 +8,11 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1 < SpreeCmCommissioner::Inte
8
8
  store_private_metadata :operator_id, :string
9
9
  store_private_metadata :sync_days, :integer, default: 7
10
10
  # override
11
+ # The dates a full sync covers: today and the next sync_days - 1 days.
12
+ def sync_dates
13
+ (0...sync_days).map { |ahead| (Date.current + ahead).iso8601 }
14
+ end
15
+
11
16
  def sync_manager
12
17
  SpreeCmCommissioner::Integrations::BookMeBusV1::SyncManager.new(integration: self)
13
18
  end
@@ -5,7 +5,12 @@ module SpreeCmCommissioner
5
5
  has_paper_trail
6
6
 
7
7
  # Why a seat was taken off sale by an operator. Stored in private_metadata rather than as a
8
- # typed column, so it cannot be an ActiveRecord enum — the constant is the source of truth.
8
+ # typed column, so it cannot be an ActiveRecord enum.
9
+ #
10
+ # Suggested options for a picklist-style UI (Transit's "Lock Seats" drawer) -- NOT a validation
11
+ # allowlist. `lock_reason` itself accepts any free text (see the presence/length validations
12
+ # below): the Organizer "Manage Hold Seats" screen lets the operator type their own reason, so
13
+ # enforcing this list at the model would reject a perfectly legitimate custom reason.
9
14
  #
10
15
  # `gender_balance` is deliberately absent: female-only seating is driven by the `gender` column,
11
16
  # and a locked row has no gender, so a lock structurally cannot express it.
@@ -25,19 +30,24 @@ module SpreeCmCommissioner
25
30
 
26
31
  enum gender: { :other => 0, :male => 1, :female => 2 }
27
32
 
33
+ # Every status in which a seat cannot be sold. Single source of truth for the `unavailable`
34
+ # scope (SQL) and `#unavailable?` (in-memory, for a caller that already preloaded the rows and
35
+ # cannot afford one query per row) -- kept as one list so a future status addition cannot update
36
+ # one and silently miss the other.
37
+ UNAVAILABLE_STATUSES = %w[reserved on_hold locked].freeze
38
+
28
39
  store_private_metadata :lock_reason, :string
29
40
  store_private_metadata :lock_note, :text
30
41
 
31
42
  validates :status, presence: true
32
43
  validates :expired_at, presence: true, if: -> { on_hold? }
33
- validates :lock_reason, inclusion: { in: LOCK_REASONS }, allow_nil: true
34
44
  validates :lock_reason, presence: true, if: -> { locked? }
35
45
  validate :locked_rows_have_no_guest, if: -> { locked? }
36
46
 
37
47
  scope :reserved_or_on_hold, -> { where(status: %i[reserved on_hold]).where('expired_at IS NULL OR expired_at > ?', Time.current) }
38
48
  # Every state in which a seat cannot be sold. `reserved_or_on_hold` is kept as-is because other
39
49
  # callers depend on its narrower meaning.
40
- scope :unavailable, -> { where(status: %i[reserved on_hold locked]).where('expired_at IS NULL OR expired_at > ?', Time.current) }
50
+ scope :unavailable, -> { where(status: UNAVAILABLE_STATUSES).where('expired_at IS NULL OR expired_at > ?', Time.current) }
41
51
  scope :active_on_hold, -> { on_hold.where('expired_at IS NULL OR expired_at > ?', Time.current) }
42
52
  scope :active, -> { where('expired_at IS NULL OR expired_at > ?', Time.current) }
43
53
 
@@ -49,6 +59,12 @@ module SpreeCmCommissioner
49
59
  on_hold? && !expired?
50
60
  end
51
61
 
62
+ # In-memory equivalent of the `unavailable` scope, for a caller iterating a preloaded
63
+ # association rather than querying -- see Block#current_reserved_block.
64
+ def unavailable?
65
+ status.in?(UNAVAILABLE_STATUSES) && !expired?
66
+ end
67
+
52
68
  def payment_locked?
53
69
  payment_locked_at.present?
54
70
  end
@@ -0,0 +1,151 @@
1
+ module SpreeCmCommissioner
2
+ # Test/abandon/fraud signals for one hold, shown in the admin inventory holds drawer. Every
3
+ # section is a single aggregate (COUNT ... FILTER) or a LIMITed pluck, so the query count is
4
+ # fixed no matter how many orders/holds the user or IP has.
5
+ class InventoryHoldInsightsQuery
6
+ RECENT_LIMIT = 10
7
+ # Shared with InventoryHoldOverviewQuery as a Postgres `~*` pattern, so keep it POSIX-compatible.
8
+ TEST_EMAIL_SOURCE = '(\+test|test@|@example\.com|@bookme\.local)'.freeze
9
+ TEST_EMAIL_PATTERN = Regexp.new(TEST_EMAIL_SOURCE, Regexp::IGNORECASE)
10
+
11
+ IP_BURST_5M = 5
12
+ IP_DISTINCT_USERS_24H = 3
13
+ USER_CONCURRENT_HOLDS = 2
14
+ USER_ABANDONED_NO_CONVERT = 3
15
+ LARGE_QUANTITY = 10
16
+
17
+ # Highest-severity flag kind wins the drawer's headline verdict.
18
+ VERDICT_ORDER = %i[fraud test abandon].freeze
19
+
20
+ def initialize(hold:)
21
+ @hold = hold
22
+ @order = hold.order
23
+ @user = hold.user || hold.order&.user
24
+ end
25
+
26
+ def call
27
+ result = {
28
+ hold: hold,
29
+ order: order,
30
+ user: user,
31
+ roles: roles,
32
+ user_orders: user_order_stats,
33
+ recent_orders: recent_orders,
34
+ user_holds: hold_stats(:user_id, user&.id),
35
+ ip_holds: hold_stats(:ip_address, hold.ip_address, distinct_users: true),
36
+ ip_recent_holds: ip_recent_holds
37
+ }
38
+ flags = flags(result)
39
+ result.merge(flags: flags, verdict: VERDICT_ORDER.find { |kind| flags[kind].any? } || :normal)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :hold, :order, :user
45
+
46
+ def roles
47
+ return [] unless user
48
+
49
+ Spree::Role.joins(:role_users).where(spree_role_users: { user_id: user.id }).pluck(:name)
50
+ end
51
+
52
+ def user_order_stats
53
+ empty = { completed: 0, incomplete: 0, last_completed_at: nil }
54
+ return empty unless user
55
+
56
+ completed, incomplete, last_completed_at = Spree::Order.where(user_id: user.id).pick(
57
+ Arel.sql('COUNT(*) FILTER (WHERE completed_at IS NOT NULL)'),
58
+ Arel.sql('COUNT(*) FILTER (WHERE completed_at IS NULL)'),
59
+ Arel.sql('MAX(completed_at)')
60
+ )
61
+ { completed: completed.to_i, incomplete: incomplete.to_i, last_completed_at: last_completed_at }
62
+ end
63
+
64
+ def recent_orders
65
+ return [] unless user
66
+
67
+ Spree::Order.where(user_id: user.id).order(created_at: :desc).limit(RECENT_LIMIT)
68
+ .pluck(:number, :state, :payment_state, :total, :currency, :created_at, :completed_at)
69
+ .map { |row| %i[number state payment_state total currency created_at completed_at].zip(row).to_h }
70
+ end
71
+
72
+ # Burst windows end at this hold's created_at so an old hold shows the burst it was part of.
73
+ def hold_stats(column, value, distinct_users: false)
74
+ keys = %i[total converted concurrent abandoned in_3m in_5m]
75
+ return keys.index_with(0).merge(distinct_users: 0, distinct_users_24h: 0) if value.blank?
76
+
77
+ relation = InventoryHold.where(column => value)
78
+
79
+ at = hold.created_at
80
+ selects = [
81
+ 'COUNT(*)',
82
+ "COUNT(*) FILTER (WHERE status = #{InventoryHold.statuses[:converted]})",
83
+ "COUNT(*) FILTER (WHERE status IN (#{InventoryHold.statuses.values_at(:active, :payment_locked).join(',')}))",
84
+ "COUNT(*) FILTER (WHERE release_reason IN (#{InventoryHold.release_reasons.values_at(:hold_expired, :payment_timeout).join(',')}))",
85
+ window_count(at - 3.minutes, at),
86
+ window_count(at - 5.minutes, at)
87
+ ]
88
+ if distinct_users
89
+ keys += %i[distinct_users distinct_users_24h]
90
+ selects << 'COUNT(DISTINCT user_id)'
91
+ selects << sanitize('COUNT(DISTINCT user_id) FILTER (WHERE created_at BETWEEN ? AND ?)', at - 24.hours, at)
92
+ end
93
+
94
+ keys.zip(relation.pick(*selects.map { |sql| Arel.sql(sql) }).map(&:to_i)).to_h
95
+ end
96
+
97
+ def window_count(from, to)
98
+ sanitize('COUNT(*) FILTER (WHERE created_at BETWEEN ? AND ?)', from, to)
99
+ end
100
+
101
+ def sanitize(sql, *binds)
102
+ InventoryHold.sanitize_sql_array([sql, *binds])
103
+ end
104
+
105
+ def ip_recent_holds
106
+ return [] if hold.ip_address.blank?
107
+
108
+ InventoryHold.where(ip_address: hold.ip_address).where.not(id: hold.id)
109
+ .left_joins(:order, :user).order(created_at: :desc).limit(RECENT_LIMIT)
110
+ .pluck(:id, :status, :created_at, 'spree_orders.number', 'spree_orders.state', 'spree_orders.payment_state', 'spree_users.email')
111
+ .map { |row| %i[id status created_at order_number order_state payment_state email].zip(row).to_h }
112
+ end
113
+
114
+ def flags(result)
115
+ { test: test_flags(result[:roles]), abandon: abandon_flags(result), fraud: fraud_flags(result) }
116
+ end
117
+
118
+ def test_flags(roles)
119
+ email = user&.email.presence || order&.email.to_s
120
+ [
121
+ ('Test-like email' if email.match?(TEST_EMAIL_PATTERN)),
122
+ ("Staff role: #{roles.join(', ')}" if roles.any?),
123
+ ('Zero total order' if order&.total.to_d.zero?),
124
+ ('Created by another user (dashboard)' if created_by_other_user?)
125
+ ].compact
126
+ end
127
+
128
+ def created_by_other_user?
129
+ order&.created_by_id.present? && order.created_by_id != order.user_id
130
+ end
131
+
132
+ def abandon_flags(result)
133
+ holds = result[:user_holds]
134
+ orders = result[:user_orders]
135
+ [
136
+ ("#{holds[:abandoned]} abandoned holds, 0 converted" if holds[:abandoned] >= USER_ABANDONED_NO_CONVERT && holds[:converted].zero?),
137
+ ("#{orders[:incomplete]} incomplete orders, 0 completed" if orders[:incomplete].positive? && orders[:completed].zero?)
138
+ ].compact
139
+ end
140
+
141
+ def fraud_flags(result)
142
+ ip = result[:ip_holds]
143
+ [
144
+ ("IP burst: #{ip[:in_5m]} holds in 5m" if ip[:in_5m] >= IP_BURST_5M),
145
+ ("#{ip[:distinct_users_24h]} different users on this IP in 24h" if ip[:distinct_users_24h] >= IP_DISTINCT_USERS_24H),
146
+ ("#{result[:user_holds][:concurrent]} holds open at once" if result[:user_holds][:concurrent] >= USER_CONCURRENT_HOLDS),
147
+ ("Large hold: #{hold.total_quantity_on_hold} items" if hold.total_quantity_on_hold.to_i >= LARGE_QUANTITY)
148
+ ].compact
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,79 @@
1
+ module SpreeCmCommissioner
2
+ # Aggregate test/abandon/fraud signals across a filtered set of holds, for the admin holds
3
+ # "Overall insights" drawer. Six grouped queries, each LIMITed, regardless of how many holds match.
4
+ class InventoryHoldOverviewQuery
5
+ TOP_LIMIT = 10
6
+ BURST_BINS = { '3m' => 180, '5m' => 300 }.freeze
7
+
8
+ # `holds` may carry ransack joins; wrapping it as an id subquery keeps the raw SQL below unambiguous.
9
+ def initialize(holds:)
10
+ @holds = InventoryHold.where(id: holds.reorder(nil).select(:id))
11
+ end
12
+
13
+ def call
14
+ {
15
+ funnel: funnel,
16
+ top_ips: top_ips,
17
+ bursts: BURST_BINS.transform_values { |seconds| bursts(seconds) },
18
+ abandoners: abandoners,
19
+ test_accounts: test_accounts
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :holds
26
+
27
+ def funnel
28
+ statuses = InventoryHold.statuses
29
+ reasons = InventoryHold.release_reasons
30
+ keys = %i[total converted open cart_abandoned payment_abandoned user_canceled cart_changed]
31
+ row = holds.pick(
32
+ Arel.sql('COUNT(*)'),
33
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{statuses[:converted]})"),
34
+ Arel.sql("COUNT(*) FILTER (WHERE status IN (#{statuses.values_at(:pending, :active, :payment_locked).join(',')}))"),
35
+ *reasons.values_at(:hold_expired, :payment_timeout, :user_canceled, :cart_changed)
36
+ .map { |value| Arel.sql("COUNT(*) FILTER (WHERE release_reason = #{value})") }
37
+ )
38
+ result = keys.zip(row.map(&:to_i)).to_h
39
+ result.merge(conversion_rate: result[:total].zero? ? 0 : (100.0 * result[:converted] / result[:total]).round(1))
40
+ end
41
+
42
+ def top_ips
43
+ holds.where.not(ip_address: [nil, '']).group(:ip_address)
44
+ .order(Arel.sql('COUNT(*) DESC')).limit(TOP_LIMIT)
45
+ .pluck(:ip_address, Arel.sql('COUNT(*)'), Arel.sql('COUNT(DISTINCT user_id)'),
46
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{InventoryHold.statuses[:converted]})")
47
+ )
48
+ .map { |row| %i[ip_address holds users converted].zip(row).to_h }
49
+ end
50
+
51
+ # Fixed-width time buckets per IP; the busiest buckets are the bursts worth checking.
52
+ def bursts(seconds)
53
+ bucket = "to_timestamp(floor(extract(epoch FROM created_at) / #{seconds.to_i}) * #{seconds.to_i})"
54
+ holds.where.not(ip_address: [nil, '']).group(:ip_address, Arel.sql(bucket))
55
+ .having('COUNT(*) > 1').order(Arel.sql('COUNT(*) DESC')).limit(TOP_LIMIT)
56
+ .pluck(:ip_address, Arel.sql(bucket), Arel.sql('COUNT(*)'), Arel.sql('COUNT(DISTINCT user_id)'))
57
+ .map { |ip, started_at, count, users| { ip_address: ip, started_at: started_at&.in_time_zone, holds: count, users: users } }
58
+ end
59
+
60
+ def abandoners
61
+ reasons = InventoryHold.release_reasons.values_at(:hold_expired, :payment_timeout).join(',')
62
+ abandoned = "COUNT(*) FILTER (WHERE cm_inventory_holds.release_reason IN (#{reasons}))"
63
+ holds.joins(:user).group(:user_id, 'spree_users.email')
64
+ .having("#{abandoned} > 0 AND COUNT(*) FILTER (WHERE cm_inventory_holds.status = #{InventoryHold.statuses[:converted]}) = 0")
65
+ .order(Arel.sql("#{abandoned} DESC")).limit(TOP_LIMIT)
66
+ .pluck(:user_id, 'spree_users.email', Arel.sql(abandoned), Arel.sql('COUNT(*)'))
67
+ .map { |row| %i[user_id email abandoned holds].zip(row).to_h }
68
+ end
69
+
70
+ def test_accounts
71
+ staff = 'EXISTS (SELECT 1 FROM spree_role_users ru WHERE ru.user_id = spree_users.id)'
72
+ holds.joins(:user)
73
+ .where("spree_users.email ~* ? OR #{staff}", InventoryHoldInsightsQuery::TEST_EMAIL_SOURCE)
74
+ .group(:user_id, 'spree_users.email').order(Arel.sql('COUNT(*) DESC')).limit(TOP_LIMIT)
75
+ .pluck(:user_id, 'spree_users.email', Arel.sql('COUNT(*)'), Arel.sql("bool_or(#{staff})"))
76
+ .map { |row| %i[user_id email holds staff].zip(row).to_h }
77
+ end
78
+ end
79
+ end
@@ -166,6 +166,13 @@ module SpreeCmCommissioner::Integrations::Base
166
166
  message: error.message,
167
167
  code: error.class.name.split('::').last
168
168
  }
169
+ # The scheduler reads this to honour Retry-After.
170
+ @error[:retry_after] = error.retry_after if error.respond_to?(:retry_after) && error.retry_after
171
+ end
172
+
173
+ # True when the last recorded error is a 429 from the partner.
174
+ def rate_limited?
175
+ @error.present? && @error[:code] == 'RateLimitedError'
169
176
  end
170
177
 
171
178
  # Check if sync was successful
@@ -41,7 +41,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
41
41
  # @param destination_id [String] The destination location ID
42
42
  # @param on_date [String] The travel date (YYYY-MM-DD format)
43
43
  # @return [Array<SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip>] Array of available trip objects
44
- def search_trips!(origin_id:, destination_id:, on_date:)
44
+ # @param lane [Symbol] :customer when a customer is waiting for the answer (see CallLimiter)
45
+ def search_trips!(origin_id:, destination_id:, on_date:, lane: :background)
45
46
  params = {
46
47
  origin_id: origin_id,
47
48
  destination_id: destination_id,
@@ -53,7 +54,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
53
54
  }.compact
54
55
  endpoint = "#{BASE_PATH}/trip_availabilities"
55
56
 
56
- response = get(endpoint, params)
57
+ response = get(endpoint, params, nil, lane: lane)
57
58
  handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip, collection: true)
58
59
  end
59
60
 
@@ -66,7 +67,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
66
67
  # @param on_date [String] The travel date (YYYY-MM-DD format)
67
68
  # @param show_all_stops [Boolean] Whether to show all stops (default: false)
68
69
  # @return [SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::SeatLayout] Seat layout object
69
- def get_seat_layout!(trip_id:, from_stop_id:, to_stop_id:, on_date:, show_all_stops: false)
70
+ # @param lane [Symbol] :customer when a customer is waiting for the answer (see CallLimiter)
71
+ def get_seat_layout!(trip_id:, from_stop_id:, to_stop_id:, on_date:, show_all_stops: false, lane: :background) # rubocop:disable Metrics/ParameterLists
70
72
  params = {
71
73
  trip_id: trip_id,
72
74
  from_stop_id: from_stop_id,
@@ -76,7 +78,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
76
78
  }
77
79
  endpoint = "#{BASE_PATH}/seat_layouts"
78
80
 
79
- response = get(endpoint, params)
81
+ response = get(endpoint, params, nil, lane: lane)
80
82
  handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::SeatLayout)
81
83
  end
82
84
 
@@ -125,7 +127,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
125
127
  request: { method: 'POST', endpoint: endpoint, body: body },
126
128
  auditable: params[:internal_order]
127
129
  ) do
128
- response = post(endpoint, body)
130
+ response = post(endpoint, body, lane: :checkout)
129
131
  handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::ReservationCart)
130
132
  end
131
133
  end
@@ -142,7 +144,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
142
144
  request: { method: 'POST', endpoint: endpoint, body: {} },
143
145
  auditable: internal_order
144
146
  ) do
145
- response = post(endpoint, {})
147
+ response = post(endpoint, {}, lane: :checkout)
146
148
  handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::WalletPayment)
147
149
  end
148
150
  end
@@ -158,7 +160,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
158
160
  request: { method: 'POST', endpoint: endpoint, body: {} },
159
161
  auditable: internal_order
160
162
  ) do
161
- response = post(endpoint, {})
163
+ response = post(endpoint, {}, lane: :checkout)
162
164
  handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::WalletPayment)
163
165
  end
164
166
  end
@@ -216,6 +218,12 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
216
218
  end
217
219
 
218
220
  def raise_api_error(response)
221
+ raise_rate_limited(response) if response.status == 429
222
+
223
+ raise_failed_request(response)
224
+ end
225
+
226
+ def raise_failed_request(response)
219
227
  status = response.status
220
228
  body = response.body
221
229
 
@@ -237,5 +245,14 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
237
245
 
238
246
  raise SpreeCmCommissioner::Integrations::ExternalClientError.new(message, status)
239
247
  end
248
+
249
+ def raise_rate_limited(response)
250
+ retry_after = SpreeCmCommissioner::Integrations::RateLimitedError.parse_retry_after(response.headers['Retry-After'])
251
+ error = SpreeCmCommissioner::Integrations::RateLimitedError.new('API request failed with status 429', retry_after: retry_after)
252
+
253
+ # Everything but checkout stops calling the partner until its Retry-After has passed.
254
+ call_limiter.pause!(error.retry_after)
255
+ raise error
256
+ end
240
257
  end
241
258
  end