spree_cm_commissioner 2.9.2 → 2.9.4

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb +14 -21
  4. data/app/controllers/concerns/spree_cm_commissioner/load_test_bypassable.rb +29 -0
  5. data/app/controllers/spree/api/chatrace/guests_controller.rb +1 -1
  6. data/app/controllers/spree/api/v2/storefront/account/guest_dynamic_fields_controller.rb +19 -48
  7. data/app/controllers/spree/api/v2/storefront/account/mark_guest_info_complete_controller.rb +2 -2
  8. data/app/controllers/spree/api/v2/storefront/account/orders_controller_decorator.rb +5 -15
  9. data/app/controllers/spree/api/v2/storefront/account/saved_guests_controller.rb +65 -0
  10. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +3 -0
  11. data/app/controllers/spree/api/v2/storefront/guests_controller.rb +10 -9
  12. data/app/controllers/spree/api/v2/storefront/order_histories_controller.rb +0 -1
  13. data/app/controllers/spree/api/v2/tenant/account/saved_guests_controller.rb +70 -0
  14. data/app/controllers/spree/api/v2/tenant/episodes_controller.rb +3 -1
  15. data/app/controllers/spree/api/v2/tenant/guests_controller.rb +11 -21
  16. data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +0 -1
  17. data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +7 -1
  18. data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +3 -14
  19. data/app/jobs/spree_cm_commissioner/maintenance_tasks/guest_data_fill_stage_batch_job.rb +28 -0
  20. data/app/models/concerns/spree_cm_commissioner/guest_data_fill_stage_concern.rb +82 -0
  21. data/app/models/concerns/spree_cm_commissioner/line_item_durationable.rb +4 -0
  22. data/app/models/concerns/spree_cm_commissioner/line_item_guests_concern.rb +1 -1
  23. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +13 -31
  24. data/app/models/spree_cm_commissioner/current.rb +6 -3
  25. data/app/models/spree_cm_commissioner/dynamic_field.rb +12 -0
  26. data/app/models/spree_cm_commissioner/guest.rb +1 -144
  27. data/app/models/spree_cm_commissioner/guest_dynamic_field.rb +43 -71
  28. data/app/models/spree_cm_commissioner/maintenance_tasks/guest_data_fill_stage.rb +21 -0
  29. data/app/models/spree_cm_commissioner/order_decorator.rb +26 -0
  30. data/app/models/spree_cm_commissioner/product_decorator.rb +0 -22
  31. data/app/models/spree_cm_commissioner/product_dynamic_field.rb +17 -0
  32. data/app/models/spree_cm_commissioner/request_guard.rb +19 -0
  33. data/app/models/spree_cm_commissioner/user_decorator.rb +26 -1
  34. data/app/models/spree_cm_commissioner/voting_contestant.rb +13 -2
  35. data/app/queries/spree_cm_commissioner/event_chart_queries.rb +2 -2
  36. data/app/queries/spree_cm_commissioner/pie_chart_event_aggregator_queries.rb +2 -2
  37. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +2 -2
  38. data/app/serializers/spree/v2/storefront/user_serializer_decorator.rb +3 -1
  39. data/app/serializers/spree/v2/tenant/saved_guest_serializer.rb +23 -0
  40. data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +1 -0
  41. data/app/serializers/spree_cm_commissioner/v2/storefront/guest_serializer.rb +3 -6
  42. data/app/serializers/spree_cm_commissioner/v2/storefront/ticket_transfer_serializer.rb +2 -2
  43. data/app/services/spree_cm_commissioner/{cart/create_guest.rb → guests/create.rb} +12 -10
  44. data/app/services/spree_cm_commissioner/guests/refresh_data_fill_stage.rb +20 -0
  45. data/app/services/spree_cm_commissioner/guests/send_dynamic_field_notification.rb +15 -0
  46. data/app/services/spree_cm_commissioner/guests/update.rb +68 -0
  47. data/app/services/spree_cm_commissioner/inventory_holds/validate_limits.rb +8 -3
  48. data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +7 -10
  49. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +83 -19
  50. data/app/services/spree_cm_commissioner/order/guests/update.rb +5 -8
  51. data/app/services/spree_cm_commissioner/voting_contestants/advancer.rb +10 -3
  52. data/app/services/spree_cm_commissioner/voting_contestants/assigner.rb +20 -2
  53. data/app/services/spree_cm_commissioner/voting_contestants/bulk_updater.rb +15 -9
  54. data/app/views/spree/admin/variants/fixture.html.erb +1 -1
  55. data/config/initializers/spree_permitted_attributes.rb +19 -0
  56. data/config/load_test_flows/tenant.yml +47 -0
  57. data/config/locales/en.yml +2 -5
  58. data/config/locales/km.yml +2 -0
  59. data/config/routes.rb +3 -1
  60. data/db/migrate/20260713022227_add_advanced_to_episode_id_to_cm_voting_contestants.rb +8 -0
  61. data/db/migrate/20260714002626_rename_data_fill_stage_phase_on_cm_guests.rb +5 -0
  62. data/lib/spree_cm_commissioner/version.rb +1 -1
  63. data/lib/tasks/recompute_guest_data_fill_stage.rake +19 -0
  64. metadata +17 -12
  65. data/app/controllers/spree/api/v2/storefront/saved_guests_controller.rb +0 -77
  66. data/app/interactors/spree_cm_commissioner/guest_dynamic_field_notification_sender.rb +0 -11
  67. data/app/interactors/spree_cm_commissioner/guest_dynamic_fields_manager.rb +0 -33
  68. data/app/serializers/spree/v2/storefront/order_serializer_decorator.rb +0 -63
  69. data/app/serializers/spree_cm_commissioner/v2/storefront/guest_minimal_serializer.rb +0 -30
  70. data/app/serializers/spree_cm_commissioner/v2/storefront/line_item_minimal_serializer.rb +0 -57
  71. data/app/serializers/spree_cm_commissioner/v2/storefront/ticket_transfer_minimal_serializer.rb +0 -17
  72. data/app/services/spree_cm_commissioner/update_guest_service.rb +0 -129
  73. data/app/services/spree_cm_commissioner/users/incomplete_guest_checker_service.rb +0 -34
@@ -52,7 +52,6 @@ module SpreeCmCommissioner
52
52
  base.scope :by_non_tenant, -> { where(tenant_id: nil) }
53
53
  base.scope :vendor_users, -> (vendor_id) { joins(:role_users => :role).where(spree_roles: { vendor_id: vendor_id }).distinct }
54
54
 
55
- base.store :public_metadata, accessors: [:has_incomplete_guest_info], coder: JSON
56
55
  base.store :private_metadata, accessors: %i[otp_secret otp_required_for_login consumed_timestep], coder: JSON
57
56
 
58
57
  base.store_public_metadata :primary_service_type, :string
@@ -66,6 +65,15 @@ module SpreeCmCommissioner
66
65
  # the id_token param. Nothing token-related is stored on the account itself.
67
66
  base.store_private_metadata :load_test_account, :boolean, default: false
68
67
 
68
+ # SQL counterpart of `load_test_account?`, so the flag can be filtered in the DB instead of
69
+ # loading rows into Ruby to call the predicate. private_metadata is stored double-encoded (a
70
+ # JSON string scalar, not a native jsonb object — see store_metadata), so unwrap the outer
71
+ # string with `#>> '{}'` before keying into it. Keeps the storage detail in one place next to
72
+ # the declaration above rather than duplicating the raw SQL in every caller.
73
+ base.scope :load_test_accounts, lambda {
74
+ where("(private_metadata #>> '{}')::jsonb ->> 'load_test_account' = 'true'")
75
+ }
76
+
69
77
  # Validations
70
78
  base.validates :primary_service_type,
71
79
  inclusion: { in: SpreeCmCommissioner::ServiceType::SERVICE_TYPES.map(&:to_s) },
@@ -223,6 +231,23 @@ module SpreeCmCommissioner
223
231
  super(request)
224
232
  end
225
233
  end
234
+
235
+ def has_incomplete_guest_info? # rubocop:disable Naming/PredicateName
236
+ # expires_in is a safety net, not the primary invalidation path — see the matching comment
237
+ # on Order#has_incomplete_guest_info?. Joins straight to guests.post_registration instead of
238
+ # calling Order#has_incomplete_guest_info? per order (which would be N+1 query calls).
239
+ Rails.cache.fetch(incomplete_guest_info_cache_key, expires_in: 1.hour) do
240
+ orders.complete.joins(:guests).merge(SpreeCmCommissioner::Guest.post_registration).exists?
241
+ end
242
+ end
243
+
244
+ def clear_has_incomplete_guest_info_cache
245
+ Rails.cache.delete(incomplete_guest_info_cache_key)
246
+ end
247
+
248
+ def incomplete_guest_info_cache_key
249
+ "user/#{id}/has_incomplete_guest_info"
250
+ end
226
251
  end
227
252
  end
228
253
 
@@ -5,6 +5,7 @@ module SpreeCmCommissioner
5
5
  belongs_to :show, class_name: 'Spree::Taxon', optional: false
6
6
  belongs_to :voting_session, class_name: 'SpreeCmCommissioner::VotingSession', optional: false
7
7
  belongs_to :show_contestant, class_name: 'SpreeCmCommissioner::ShowContestant', optional: false
8
+ belongs_to :advanced_to_episode, class_name: 'SpreeCmCommissioner::ShowEpisode', optional: true
8
9
  belongs_to :advanced_to, polymorphic: true, optional: true
9
10
  belongs_to :advanced_from, polymorphic: true, optional: true
10
11
  has_many :votes, class_name: 'SpreeCmCommissioner::Vote', foreign_key: :contestant_id, dependent: :restrict_with_error
@@ -37,6 +38,7 @@ module SpreeCmCommissioner
37
38
  # | 1 | 1 | 2 | ✓ OK | Can update/change number |
38
39
  # | 1 | 2 | 1 | ✓ OK | Different session |
39
40
  validates :vote_number, uniqueness: { scope: :voting_session_id, allow_nil: true }
41
+ validate :advanced_to_and_advanced_from_must_differ
40
42
 
41
43
  delegate :name, :contestant_number, :category, :gender, :eliminated_at, to: :show_contestant, allow_nil: true
42
44
  delegate :can_vote?, to: :voting_session, allow_nil: true
@@ -50,11 +52,11 @@ module SpreeCmCommissioner
50
52
  end
51
53
 
52
54
  def advanced_from_name
53
- advanced_from&.name
55
+ [advanced_from&.episode&.name, advanced_from&.name].compact.join(' - ')
54
56
  end
55
57
 
56
58
  def advanced_to_name
57
- advanced_to&.name
59
+ [advanced_to&.episode&.name || advanced_to_episode&.name, advanced_to&.name].compact.join(' - ')
58
60
  end
59
61
 
60
62
  private
@@ -97,5 +99,14 @@ module SpreeCmCommissioner
97
99
  errors.add(:base, :has_advanced_to_session)
98
100
  throw :abort
99
101
  end
102
+
103
+ # Prevent a contestant from being advanced to the same record it was advanced from,
104
+ # which would create a self-referencing loop between sessions.
105
+ def advanced_to_and_advanced_from_must_differ
106
+ return if advanced_to_id.blank? || advanced_from_id.blank?
107
+ return unless advanced_to_type == advanced_from_type && advanced_to_id == advanced_from_id
108
+
109
+ errors.add(:advanced_to_id, :same_as_advanced_from)
110
+ end
100
111
  end
101
112
  end
@@ -59,7 +59,7 @@ module SpreeCmCommissioner
59
59
  WHEN cm_check_ins.id IS NOT NULL THEN 'show'
60
60
  ELSE 'no_show'
61
61
  END AS name,
62
- COUNT(*) AS total"
62
+ COUNT(DISTINCT cm_guests.id) AS total"
63
63
  )
64
64
  .group('spree_products.id, CASE WHEN cm_check_ins.id IS NOT NULL THEN \'show\' ELSE \'no_show\' END')
65
65
  .group_by(&:product_id)
@@ -98,7 +98,7 @@ module SpreeCmCommissioner
98
98
  WHEN cm_check_ins.entry_type = 0 THEN 'normal'
99
99
  WHEN cm_check_ins.entry_type = 1 THEN 'VIP'
100
100
  END AS name,
101
- COUNT(cm_check_ins.id) AS total"
101
+ COUNT(DISTINCT cm_guests.id) AS total"
102
102
  )
103
103
  .group('spree_products.id, cm_check_ins.entry_type')
104
104
  .group_by(&:product_id)
@@ -58,7 +58,7 @@ module SpreeCmCommissioner
58
58
  WHEN cm_check_ins.id IS NOT NULL THEN 'show'
59
59
  ELSE 'no_show'
60
60
  END AS name,
61
- COUNT(*) AS total"
61
+ COUNT(DISTINCT cm_guests.id) AS total"
62
62
  )
63
63
  .group('spree_products.id, CASE WHEN cm_check_ins.id IS NOT NULL THEN \'show\' ELSE \'no_show\' END')
64
64
  .group_by(&:product_id)
@@ -110,7 +110,7 @@ module SpreeCmCommissioner
110
110
  CASE WHEN cm_check_ins.entry_type = 0 THEN 'normal'
111
111
  WHEN cm_check_ins.entry_type = 1 THEN 'VIP'
112
112
  ELSE NULL END AS name,
113
- COUNT(cm_check_ins.id) AS total"
113
+ COUNT(DISTINCT cm_guests.id) AS total"
114
114
  )
115
115
  .where.not(cm_check_ins: { id: nil })
116
116
  .group('spree_products.id, cm_check_ins.entry_type')
@@ -35,11 +35,11 @@ module Spree
35
35
 
36
36
  base.has_one :google_wallet, serializer: SpreeCmCommissioner::V2::Storefront::GoogleWalletSerializer
37
37
 
38
- base.has_many :guests, serializer: SpreeCmCommissioner::V2::Storefront::GuestMinimalSerializer
38
+ base.has_many :guests, serializer: SpreeCmCommissioner::V2::Storefront::GuestSerializer
39
39
 
40
40
  base.has_many :saved_guests, serializer: SpreeCmCommissioner::V2::Storefront::SavedGuestSerializer
41
41
 
42
- base.has_many :pending_guests, serializer: SpreeCmCommissioner::V2::Storefront::GuestMinimalSerializer
42
+ base.has_many :pending_guests, serializer: SpreeCmCommissioner::V2::Storefront::GuestSerializer
43
43
 
44
44
  base.has_one :product, serializer: Spree::V2::Storefront::ProductSerializer
45
45
 
@@ -5,10 +5,12 @@ module Spree
5
5
  def self.prepended(base)
6
6
  base.attributes :first_name, :last_name, :gender, :phone_number, :intel_phone_number,
7
7
  :country_code, :otp_enabled, :otp_email, :otp_phone_number,
8
- :confirm_pin_code_enabled, :tenant_id, :has_incomplete_guest_info,
8
+ :confirm_pin_code_enabled, :tenant_id,
9
9
  :login, :qr_data, :qr_data_version, :qr_data_invalidated_at,
10
10
  :unread_notifications_count, :request_orders_count
11
11
 
12
+ base.attribute :has_incomplete_guest_info, &:has_incomplete_guest_info?
13
+
12
14
  base.has_one :profile, serializer: ::Spree::V2::Storefront::UserProfileSerializer
13
15
  base.has_many :device_tokens, serializer: Spree::V2::Storefront::UserDeviceTokenSerializer
14
16
  base.has_many :spree_roles, serializer: Spree::V2::Storefront::RoleSerializer
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module V2
3
+ module Tenant
4
+ class SavedGuestSerializer < BaseSerializer
5
+ attributes :first_name,
6
+ :last_name,
7
+ :dob,
8
+ :age,
9
+ :gender,
10
+ :email,
11
+ :country_code,
12
+ :phone_number,
13
+ :intel_phone_number,
14
+ :user_id,
15
+ :age_group,
16
+ :nationality_group
17
+
18
+ belongs_to :occupation, serializer: Spree::V2::Tenant::TaxonSerializer
19
+ belongs_to :nationality, serializer: Spree::V2::Tenant::TaxonSerializer
20
+ end
21
+ end
22
+ end
23
+ end
@@ -11,6 +11,7 @@ module SpreeCmCommissioner
11
11
  :term_accepted_at,
12
12
  :channel, :hold_expires_at
13
13
 
14
+ base.attribute :has_incomplete_guest_info, &:has_incomplete_guest_info?
14
15
  base.attribute :qr_data do |order|
15
16
  order.qr_data if order.completed?
16
17
  end
@@ -7,13 +7,10 @@ module SpreeCmCommissioner
7
7
  attributes :first_name, :last_name, :dob, :gender, :kyc_fields, :other_occupation, :created_at, :updated_at,
8
8
  :qr_data, :social_contact_platform, :social_contact, :available_social_contact_platforms,
9
9
  :age, :emergency_contact, :other_organization, :expectation, :upload_later, :address, :formatted_bib_number, :phone_number,
10
- :token, :country_code, :contact, :data_fill_stage_phase,
10
+ :token, :country_code, :contact, :seat_number,
11
11
  :saved_guest_id, :state
12
12
 
13
- # temporary, once app release after cm-app v1.9.1, we no longer need this.
14
- attribute :seat_number do |object|
15
- object.seat_number || object.formatted_bib_number
16
- end
13
+ attribute :data_fill_stage_phase, &:data_fill_stage
17
14
 
18
15
  attribute :allowed_checkout, &:allowed_checkout?
19
16
  attribute :allowed_upload_later, &:allowed_upload_later?
@@ -30,7 +27,7 @@ module SpreeCmCommissioner
30
27
 
31
28
  has_many :check_ins, serializer: SpreeCmCommissioner::V2::Storefront::CheckInSerializer
32
29
 
33
- has_many :ticket_transfers, serializer: SpreeCmCommissioner::V2::Storefront::TicketTransferMinimalSerializer
30
+ has_many :ticket_transfers, serializer: SpreeCmCommissioner::V2::Storefront::TicketTransferSerializer
34
31
 
35
32
  # allowed_checkout updates frequently
36
33
  cache_options store: nil
@@ -9,8 +9,8 @@ module SpreeCmCommissioner
9
9
 
10
10
  belongs_to :from_user, serializer: ::Spree::V2::Storefront::UserSerializer
11
11
  belongs_to :to_user, serializer: ::Spree::V2::Storefront::UserSerializer
12
- belongs_to :from_guest, serializer: SpreeCmCommissioner::V2::Storefront::GuestMinimalSerializer
13
- belongs_to :to_guest, serializer: SpreeCmCommissioner::V2::Storefront::GuestMinimalSerializer
12
+ belongs_to :from_guest, serializer: SpreeCmCommissioner::V2::Storefront::GuestSerializer
13
+ belongs_to :to_guest, serializer: SpreeCmCommissioner::V2::Storefront::GuestSerializer
14
14
  belongs_to :event, serializer: Spree::V2::Storefront::TaxonSerializer
15
15
  belongs_to :product, serializer: Spree::V2::Storefront::ProductSerializer
16
16
  belongs_to :order, serializer: ::Spree::V2::Storefront::OrderSerializer
@@ -2,14 +2,16 @@
2
2
  # the source of truth, so quantity is raised to cover every guest (never trimmed), then
3
3
  # the cart is recalculated. The line item is locked so the insert + quantity sync are
4
4
  # atomic, mirroring Cart::AddGuest and preventing concurrent / double-triggered creates
5
- # from clobbering the quantity.
5
+ # from clobbering the quantity. Quantity/hold adjustments are skipped once the order is
6
+ # completed (e.g. a vendor adding a guest to an already-paid booking) so this stays safe
7
+ # to call from both the storefront cart flow and tenant booking-on-behalf-of flow.
6
8
  module SpreeCmCommissioner
7
- module Cart
8
- class CreateGuest
9
+ module Guests
10
+ class Create
9
11
  prepend Spree::ServiceModule::Base
10
12
 
11
- def call(line_item:, options: {})
12
- guest = line_item.guests.new(options)
13
+ def call(line_item:, guest_params: {})
14
+ guest = line_item.guests.new(guest_params)
13
15
 
14
16
  # Release holds BEFORE mutating, using the quantities the hold was placed against
15
17
  # (Release reads live line items). Only release when this guest will actually bump
@@ -22,16 +24,15 @@ module SpreeCmCommissioner
22
24
  line_item.with_lock do
23
25
  next unless guest.save
24
26
 
25
- guest.save_and_move_to_next_stage
26
27
  bump_quantity_to_cover_guests(line_item) if need_to_bump_quantity?(line_item)
27
28
  end
28
29
 
29
30
  return failure(guest) unless guest.persisted?
30
31
 
31
- success(guest)
32
+ success(guest: guest)
32
33
  rescue StandardError => e
33
34
  CmAppLogger.error(
34
- label: 'SpreeCmCommissioner::Cart::CreateGuest#call',
35
+ label: 'SpreeCmCommissioner::Guests::Create#call',
35
36
  data: {
36
37
  error_class: e.class.name,
37
38
  error_message: e.message
@@ -44,9 +45,10 @@ module SpreeCmCommissioner
44
45
 
45
46
  # Raise quantity one unit at a time (each unit adds variant.number_of_guests of
46
47
  # capacity) until number_of_guests covers guests.count, then recalculate. Skipped for
47
- # custom guest-count metadata (accommodation/transit) and variants with no per-unit
48
- # guest capacity.
48
+ # custom guest-count metadata (accommodation/transit), variants with no per-unit
49
+ # guest capacity, and completed orders (quantity is no longer adjustable post-payment).
49
50
  def need_to_bump_quantity?(line_item)
51
+ return false if line_item.order.completed?
50
52
  return false if line_item.custom_guest_count?
51
53
  return false if line_item.variant.number_of_guests.to_i <= 0
52
54
 
@@ -0,0 +1,20 @@
1
+ module SpreeCmCommissioner
2
+ module Guests
3
+ class RefreshDataFillStage
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ def call(guest:, notify: true)
7
+ # incomplete? is always live, so it can't detect a before/after transition on its own —
8
+ # comparing it to itself would always agree. completed? reads the persisted (possibly
9
+ # stale) column instead, which is the actual "before" state worth comparing against.
10
+ was_marked_complete = guest.completed?
11
+
12
+ guest.refresh_data_fill_stage
13
+
14
+ SpreeCmCommissioner::Guests::SendDynamicFieldNotification.call(guest: guest) if notify && was_marked_complete && guest.incomplete?
15
+
16
+ success(guest: guest)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ module SpreeCmCommissioner
2
+ module Guests
3
+ class SendDynamicFieldNotification
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ def call(guest:)
7
+ notification = SpreeCmCommissioner::GuestDynamicFieldNotification.with(
8
+ guest: guest
9
+ ).deliver_later(guest.user)
10
+
11
+ success(notification: notification)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ module SpreeCmCommissioner
2
+ module Guests
3
+ # Updates guest records with dynamic fields.
4
+ #
5
+ # Per-field replace semantics: for every dynamic_field_id mentioned in the payload, the
6
+ # submitted rows are the complete set of answers for that field; unmentioned fields are
7
+ # untouched. This still handles checkbox deselection (omit that option's row) without a
8
+ # submit containing only e.g. pre_registration fields wiping post_registration answers.
9
+ class Update
10
+ prepend ::Spree::ServiceModule::Base
11
+
12
+ attr_reader :guest, :guest_params
13
+
14
+ def call(guest:, guest_params:)
15
+ @guest = guest
16
+ @guest_params = guest_params
17
+
18
+ ActiveRecord::Base.transaction do
19
+ replace_dynamic_fields(guest_params[:guest_dynamic_fields_attributes]) if guest_params[:guest_dynamic_fields_attributes]
20
+ guest.update!(guest_params.except(:guest_dynamic_fields_attributes))
21
+ end
22
+
23
+ success(guest: guest)
24
+ rescue ActiveRecord::RecordInvalid => e
25
+ failure(e.record, e.record.errors)
26
+ end
27
+
28
+ private
29
+
30
+ def replace_dynamic_fields(dynamic_fields_attributes)
31
+ rows = dynamic_fields_attributes.map { |raw| raw.to_h.symbolize_keys }
32
+
33
+ to_destroy, to_upsert = rows.partition { |attrs| ActiveModel::Type::Boolean.new.cast(attrs[:_destroy]) }
34
+ to_destroy.each { |attrs| destroy_guest_dynamic_field(attrs[:id]) }
35
+
36
+ kept = to_upsert.map { |attrs| upsert_guest_dynamic_field(attrs) }
37
+
38
+ touched_field_ids = kept.map(&:dynamic_field_id)
39
+ guest.guest_dynamic_fields.where(dynamic_field_id: touched_field_ids).where.not(id: kept.map(&:id)).destroy_all
40
+ end
41
+
42
+ # Falls back to (dynamic_field_id, dynamic_field_option_id) when no id is given (e.g. Order::Guests::Update,
43
+ # which never has one) so a resubmit updates the prior row instead of creating a new one and relying on the
44
+ # cleanup pass below to delete the old one. This also collapses duplicate option ids submitted in one
45
+ # request: the second lookup finds the row the first upsert just inserted, in the same transaction.
46
+ def upsert_guest_dynamic_field(attrs)
47
+ field = if attrs[:id].present?
48
+ guest.guest_dynamic_fields.find_by(id: attrs[:id])
49
+ else
50
+ guest.guest_dynamic_fields.find_by(
51
+ dynamic_field_id: attrs[:dynamic_field_id],
52
+ dynamic_field_option_id: attrs[:dynamic_field_option_id]
53
+ )
54
+ end
55
+ field ||= guest.guest_dynamic_fields.build
56
+ field.assign_attributes(attrs.except(:id, :_destroy))
57
+ field.save!
58
+ field
59
+ end
60
+
61
+ def destroy_guest_dynamic_field(id)
62
+ return if id.blank?
63
+
64
+ guest.guest_dynamic_fields.find_by(id: id)&.destroy!
65
+ end
66
+ end
67
+ end
68
+ end
@@ -3,6 +3,7 @@ module SpreeCmCommissioner
3
3
  class ValidateLimits
4
4
  prepend ::Spree::ServiceModule::Base
5
5
  extend SpreeCmCommissioner::ServiceModuleThrowable
6
+ include SpreeCmCommissioner::RequestGuard
6
7
 
7
8
  ExceededActiveHoldsError = Class.new(StandardError)
8
9
  IpRateLimitError = Class.new(StandardError)
@@ -14,9 +15,13 @@ module SpreeCmCommissioner
14
15
 
15
16
  # Validates all inventory hold limits for the given order
16
17
  def call(order:)
17
- validate_user_hold_limits!(order) if order.user.present?
18
- validate_ip_rate_limit!(order) unless SpreeCmCommissioner::Current.load_test_bypass
19
- validate_cooldown!(order)
18
+ # Skipped for trusted load-test requests (see RequestGuard/LoadTestBypassable).
19
+ # Safe: overselling is still blocked by the Redis stock check in Acquire.
20
+ with_request_guard do
21
+ validate_user_hold_limits!(order) if order.user.present?
22
+ validate_ip_rate_limit!(order)
23
+ validate_cooldown!(order)
24
+ end
20
25
 
21
26
  success(nil)
22
27
  rescue StandardError => e
@@ -7,10 +7,6 @@ module SpreeCmCommissioner
7
7
  class DestroyAccounts
8
8
  prepend ::Spree::ServiceModule::Base
9
9
 
10
- # Scan cap for the candidate set below — bounds the query while comfortably covering realistic
11
- # load-test pool sizes (mirrors the admin controller's export scan).
12
- SCAN_LIMIT = 2000
13
-
14
10
  def call(client_id: nil, count: 20, **_ignored)
15
11
  tenant = resolve_tenant(client_id)
16
12
 
@@ -24,14 +20,15 @@ module SpreeCmCommissioner
24
20
 
25
21
  private
26
22
 
27
- # `load_test_account` lives in the `private_metadata` jsonb store (double-encoded as a JSON
28
- # string scalar), so it can't be filtered in SQL load the tenant's candidates and filter
29
- # with the model's own `load_test_account?` in Ruby (same approach as the admin controller).
23
+ # Filters the `load_test_account` flag in SQL via the model's `load_test_accounts` scope (see
24
+ # UserDecorator), newest first so a fresh pool is destroyed before older leftovers. No scan
25
+ # cap: works even on tenants with far more than 2,000 users, unlike the old
26
+ # `order(:id).limit(2000).select(&:...)` approach. `.first(count)` in #call adds the LIMIT, so
27
+ # this stays a single query.
30
28
  def load_test_accounts(tenant)
31
29
  Spree::User.where(tenant_id: tenant&.id)
32
- .order(:id)
33
- .limit(SCAN_LIMIT)
34
- .select(&:load_test_account?)
30
+ .load_test_accounts
31
+ .order(id: :desc)
35
32
  end
36
33
 
37
34
  def resolve_tenant(client_id)
@@ -1,22 +1,43 @@
1
1
  module SpreeCmCommissioner
2
2
  module LoadTest
3
- # Generates the booking_ticket_flow load-test data for one variant so it can be used by the
4
- # k6 booking flow. An admin picks a ticket variant in the dashboard; this reads its
5
- # product / variant / event straight off the models and returns the payload the flow needs,
6
- # plus a "can this actually be booked?" check so the tester knows it will drive the flow.
3
+ # Generates the cash_on_booking_ticket_flow / aba_booking_ticket_flow load-test data for one variant
4
+ # so it can be used by the k6 booking flow. An admin/organizer picks a ticket variant in the
5
+ # dashboard; this reads its product / variant / event straight off the models and returns the
6
+ # payload the flow needs, plus a "can this actually be booked?" check so the tester knows it
7
+ # will drive the flow. Mirrors GenerateVotingFixtureData's cash-on/ABA payment resolution
8
+ # (gems/spree_cm_commissioner/app/services/spree_cm_commissioner/load_test/
9
+ # generate_voting_fixture_data.rb) for the same two payment methods, applied to a variant
10
+ # instead of a voting session.
7
11
  #
8
- # Flow: pick a ticket variant in admin -> copy this payload into
9
- # load_testing/config/fixture/platform/booking_ticket_flow/<env>.json -> Env.fixture() reads
10
- # that file when running k6 (load_testing/tests/platform/booking_ticket_flow).
12
+ # Flow: pick a ticket variant in admin/organizer -> copy this payload into
13
+ # load_testing/config/fixture/<platform|tenant>/<flow>/<env>.json -> Env.fixture()/
14
+ # Env.optionalFixture() reads that file when running k6. Note: the k6 flows themselves still
15
+ # read the actual payment_method_id/payway config from env vars (TENANT_PAYMENT_METHOD_ID,
16
+ # TENANT_PAYWAY_*), not from this JSON — payment_method_id/payway are included here purely so
17
+ # whoever generates the fixture can see (and copy) what's actually configured, same as
18
+ # GenerateVotingFixtureData's topup flows.
11
19
  #
12
- # Returns success({ data: {...}, issues: [...] }). `issues` is empty when the variant
13
- # is ready to load-test; otherwise it lists why the booking flow would fail. The same
14
- # `issues` (plus a `ready` flag) are duplicated into `data[:metadata]` so they travel with
15
- # the copied JSON — the k6 flow ignores that key, it's there for a human to read later.
20
+ # Returns success({ data: {...}, issues: [...] }). `issues` is empty when the variant is ready
21
+ # to load-test; otherwise it lists why the booking flow would fail including "no payment
22
+ # method configured" for whichever flow you asked for. `issues` (plus a `ready` flag) are
23
+ # duplicated into `data[:metadata]` so they travel with the copied JSON — the k6 flow ignores
24
+ # that key, it's there for a human to read later.
16
25
  class GenerateBookingTicketData
17
26
  prepend ::Spree::ServiceModule::Base
18
27
 
19
- def call(variant:)
28
+ FLOWS = %w[cash_on_booking_ticket_flow aba_booking_ticket_flow].freeze
29
+
30
+ # ABA's checkout endpoint is fixed regardless of tenant/environment — only `host` (sandbox
31
+ # vs production) differs, and that comes off the payment method. See
32
+ # Vpago::PaywayV2::Checkout#checkout_url in gems/spree_vpago. Mirrors
33
+ # GenerateVotingFixtureData::PAYWAY_CHECKOUT_PATH.
34
+ PAYWAY_CHECKOUT_PATH = '/api/payment-gateway/v1/payments/purchase'.freeze
35
+
36
+ # flow: which payment method to resolve — 'cash_on_booking_ticket_flow' (the default) or
37
+ # 'aba_booking_ticket_flow' (ABA/PayWay v2, adds a `payway` block).
38
+ def call(variant:, flow: 'cash_on_booking_ticket_flow')
39
+ raise ArgumentError, "Unknown flow: #{flow.inspect} (expected one of: #{FLOWS.join(', ')})" unless FLOWS.include?(flow)
40
+
20
41
  product = variant.product
21
42
  event = product.event
22
43
  issues = issues_for(variant, event)
@@ -25,15 +46,15 @@ module SpreeCmCommissioner
25
46
  event_permalink: event&.permalink,
26
47
  product_slug: product.slug,
27
48
  product_id: product.id,
28
- variant_id: variant.id,
29
- # Informational only — the k6 flow never reads this. Carries the readiness check into
30
- # the copied JSON itself, so it's visible later without regenerating from admin.
31
- metadata: {
32
- ready: issues.empty?,
33
- issues: issues
34
- }
49
+ variant_id: variant.id
35
50
  }
36
51
 
52
+ issues.concat(payment_method_issues(variant, flow, data))
53
+
54
+ # Informational only — the k6 flow never reads this. Carries the readiness check into
55
+ # the copied JSON itself, so it's visible later without regenerating from admin/organizer.
56
+ data[:metadata] = { ready: issues.empty?, issues: issues }
57
+
37
58
  success(data: data, issues: issues)
38
59
  rescue StandardError => e
39
60
  failure(nil, e.message)
@@ -41,6 +62,49 @@ module SpreeCmCommissioner
41
62
 
42
63
  private
43
64
 
65
+ # Resolves the payment method for `flow` into `data`, and (for ABA) its payway payload.
66
+ # Returns the readiness issues — "no payment method configured" is blocking, same as
67
+ # GenerateVotingFixtureData's topup_flow_issues.
68
+ def payment_method_issues(variant, flow, data)
69
+ payment_method = resolve_payment_method(variant.vendor, flow)
70
+
71
+ return ["No #{payment_method_label(flow)} payment method configured for this tenant."] if payment_method.nil?
72
+
73
+ data[:payment_method_id] = payment_method.id
74
+ return [] unless flow == 'aba_booking_ticket_flow'
75
+
76
+ data[:payway] = payway_payload(payment_method)
77
+ return [] if payment_method.preferred_host.to_s.match?(/sandbox/i)
78
+
79
+ [
80
+ "ABA payment method's host (#{payment_method.preferred_host.inspect}) doesn't look like a sandbox URL — " \
81
+ 'load tests should run against ABA\'s sandbox, not production.'
82
+ ]
83
+ end
84
+
85
+ def resolve_payment_method(vendor, flow)
86
+ type = flow == 'aba_booking_ticket_flow' ? Vpago::PaymentMethodDecorator::TYPE_PAYWAY_V2 : Vpago::PaymentMethodDecorator::TYPE_CASH_ON
87
+
88
+ Spree::PaymentMethod.active.where(vendor_id: vendor&.id, type: type).first
89
+ end
90
+
91
+ def payment_method_label(flow)
92
+ flow == 'aba_booking_ticket_flow' ? 'ABA' : 'cash-on'
93
+ end
94
+
95
+ def payway_payload(payment_method)
96
+ {
97
+ host: payment_method.preferred_host,
98
+ checkout_path: PAYWAY_CHECKOUT_PATH,
99
+ merchant_id: payment_method.preferred_merchant_id,
100
+ api_key: payment_method.preferred_api_key,
101
+ payment_option: payment_method.preferred_payment_option,
102
+ transaction_fee_fix: payment_method.preferred_transaction_fee_fix.to_f,
103
+ transaction_fee_percentage: payment_method.preferred_transaction_fee_percentage.to_f,
104
+ enable_pre_auth: payment_method.enable_pre_auth?
105
+ }
106
+ end
107
+
44
108
  # Each check returns an explanatory string when it would break the booking flow, or nil when the
45
109
  # variant is fine on that axis. Order mirrors the flow: availability first, then checkout shape.
46
110
  def issues_for(variant, event)
@@ -3,7 +3,7 @@ module SpreeCmCommissioner
3
3
  module Guests
4
4
  # Whitelists submitted guest params down to whatever KYC fields and dynamic
5
5
  # fields are actually configured for the guest's line item, then delegates
6
- # persistence to UpdateGuestService.
6
+ # persistence to SpreeCmCommissioner::Guests::Update.
7
7
  #
8
8
  # Expects params shaped as:
9
9
  # { first_name: '...', ..., dynamic_fields: { '<dynamic_field_id>' => value_or_option_id_or_array_of_option_ids } }
@@ -11,10 +11,7 @@ module SpreeCmCommissioner
11
11
  prepend ::Spree::ServiceModule::Base
12
12
 
13
13
  def call(guest:, params:)
14
- SpreeCmCommissioner::UpdateGuestService.new(guest, permitted_params(guest, params)).call
15
- success(guest: guest)
16
- rescue ActiveRecord::RecordInvalid => e
17
- failure(guest, e.record.errors.full_messages.to_sentence)
14
+ SpreeCmCommissioner::Guests::Update.call(guest: guest, guest_params: permitted_params(guest, params))
18
15
  end
19
16
 
20
17
  private
@@ -28,11 +25,11 @@ module SpreeCmCommissioner
28
25
  filtered
29
26
  end
30
27
 
31
- # Builds the guest_dynamic_fields_attributes array UpdateGuestService expects,
32
- # restricted to dynamic fields actually enabled for this guest's product (one
28
+ # Builds the guest_dynamic_fields_attributes array SpreeCmCommissioner::Guests::Update
29
+ # expects, restricted to dynamic fields actually enabled for this guest's product (one
33
30
  # `pluck`/load, not a query per submitted field). Option-based fields (checkbox/
34
31
  # radio/selection) submit one or more selected option ids and become one
35
- # GuestDynamicField row per selected option; UpdateGuestService's own auto-cleanup
32
+ # GuestDynamicField row per selected option; Guests::Update's own auto-cleanup
36
33
  # destroys any existing rows for options that were deselected.
37
34
  def build_dynamic_field_attributes(guest, dynamic_fields)
38
35
  return [] if dynamic_fields.blank?
@@ -63,9 +63,15 @@ module SpreeCmCommissioner
63
63
  # --- Elimination ---------------------------------------------------------
64
64
 
65
65
  def sync_elimination_status
66
- # No destination id contestant was never advanced forward; nothing to mirror.
67
- # e.g. vc_a { advanced_to_id: nil } return
68
- return if @voting_contestant.advanced_to_id.blank?
66
+ # Episode-only advancement (no session mirror was ever created) just
67
+ # drop the marker when eliminated; nothing to unwind.
68
+ # e.g. vc_a { advanced_to_id: nil, advanced_to_episode_id: 5, eliminated: true } → clear it
69
+ if @voting_contestant.advanced_to_id.blank?
70
+ if @voting_contestant.eliminated? && @voting_contestant.advanced_to_episode_id.present?
71
+ @voting_contestant.update!(advanced_to_episode_id: nil)
72
+ end
73
+ return
74
+ end
69
75
 
70
76
  # Incomplete polymorphic ref — can't resolve a session without both parts.
71
77
  # e.g. vc_a { advanced_to_type: nil } → return
@@ -203,6 +209,7 @@ module SpreeCmCommissioner
203
209
  @voting_contestant.update!(
204
210
  advanced_to_type: nil,
205
211
  advanced_to_id: nil,
212
+ advanced_to_episode_id: nil,
206
213
  updated_at: Time.current
207
214
  )
208
215
  end