spree_cm_commissioner 2.9.1.pre.pre3 → 2.9.2

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +9 -0
  3. data/Gemfile.lock +2 -2
  4. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +7 -0
  5. data/app/controllers/concerns/spree_cm_commissioner/load_test_account_management.rb +80 -0
  6. data/app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb +46 -0
  7. data/app/controllers/concerns/spree_cm_commissioner/load_test_login_token_generation.rb +53 -0
  8. data/app/controllers/spree/admin/guests_controller.rb +4 -2
  9. data/app/controllers/spree/admin/system/load_test_accounts_controller.rb +24 -0
  10. data/app/controllers/spree/admin/system/load_test_flows_controller.rb +11 -0
  11. data/app/controllers/spree/admin/system/load_test_login_tokens_controller.rb +16 -0
  12. data/app/controllers/spree/admin/system/load_testing_controller.rb +9 -0
  13. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +48 -0
  14. data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -7
  15. data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +28 -0
  16. data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +49 -0
  17. data/app/controllers/spree/api/v2/tenant/preview_products_controller.rb +1 -1
  18. data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -7
  19. data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +11 -0
  20. data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +0 -2
  21. data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +105 -0
  22. data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +71 -0
  23. data/app/interactors/spree_cm_commissioner/user_id_token_authenticator.rb +2 -5
  24. data/app/interactors/spree_cm_commissioner/user_password_authenticator.rb +23 -1
  25. data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +78 -0
  26. data/app/mailers/spree/order_mailer_decorator.rb +1 -0
  27. data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +17 -0
  28. data/app/models/concerns/spree_cm_commissioner/line_items_filter_scope.rb +5 -3
  29. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +11 -0
  30. data/app/models/concerns/spree_cm_commissioner/product_delegation.rb +2 -0
  31. data/app/models/spree_cm_commissioner/homepage_section_relatable.rb +25 -0
  32. data/app/models/spree_cm_commissioner/order_decorator.rb +0 -6
  33. data/app/models/spree_cm_commissioner/payment_method_decorator.rb +2 -0
  34. data/app/models/spree_cm_commissioner/pricing_model_route.rb +8 -0
  35. data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
  36. data/app/models/spree_cm_commissioner/trip.rb +13 -1
  37. data/app/models/spree_cm_commissioner/user_decorator.rb +6 -0
  38. data/app/overrides/spree/admin/payment_methods/index/pagination.html.erb.deface +3 -0
  39. data/app/overrides/spree/admin/payment_methods/index/preserve_tab_in_search.html.erb.deface +4 -0
  40. data/app/overrides/spree/admin/products/_form/enable_social_sharing_email.html.erb.deface +13 -0
  41. data/app/overrides/spree/admin/variants/index/fixture_action.html.erb.deface +9 -0
  42. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +2 -0
  43. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -2
  44. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +3 -1
  45. data/app/services/spree_cm_commissioner/draft_order/create.rb +86 -0
  46. data/app/services/spree_cm_commissioner/load_test/create_accounts.rb +68 -0
  47. data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +50 -0
  48. data/app/services/spree_cm_commissioner/load_test/flow_registry.rb +26 -0
  49. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +140 -0
  50. data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +178 -0
  51. data/app/services/spree_cm_commissioner/load_test/login_token.rb +46 -0
  52. data/app/services/spree_cm_commissioner/order/guests/update.rb +56 -0
  53. data/app/services/spree_cm_commissioner/pricing_models/create_with_rule_groups.rb +9 -3
  54. data/app/services/spree_cm_commissioner/pricing_models/preview.rb +49 -6
  55. data/app/services/spree_cm_commissioner/pricing_models/update_with_rule_groups.rb +10 -7
  56. data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +6 -1
  57. data/app/services/spree_cm_commissioner/user_authenticator.rb +22 -1
  58. data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +13 -2
  59. data/app/views/spree/admin/featured_trips/index.html.erb +1 -1
  60. data/app/views/spree/admin/guests/_form.html.erb +37 -0
  61. data/app/views/spree/admin/shared/_system_tabs.html.erb +7 -0
  62. data/app/views/spree/admin/system/load_test_accounts/_login_tokens_modal.html.erb +52 -0
  63. data/app/views/spree/admin/system/load_test_accounts/index.html.erb +175 -0
  64. data/app/views/spree/admin/system/load_test_flows/index.html.erb +66 -0
  65. data/app/views/spree/admin/system/load_testing/index.html.erb +25 -0
  66. data/app/views/spree/admin/variants/fixture.html.erb +49 -0
  67. data/config/initializers/doorkeeper.rb +10 -6
  68. data/config/initializers/spree_permitted_attributes.rb +30 -0
  69. data/config/load_test_flows/platform.yml +25 -0
  70. data/config/load_test_flows/tenant.yml +46 -0
  71. data/config/locales/en.yml +94 -0
  72. data/config/locales/km.yml +2 -0
  73. data/config/routes.rb +19 -0
  74. data/db/migrate/20260630000001_add_trip_to_cm_pricing_model_routes.rb +7 -0
  75. data/db/migrate/20260703065239_add_position_to_cm_voting_contestants.rb +1 -1
  76. data/db/migrate/20260708150000_add_name_and_short_name_to_cm_trips.rb +6 -0
  77. data/lib/spree_cm_commissioner/test_helper/factories/pricing_model_trip_factory.rb +11 -0
  78. data/lib/spree_cm_commissioner/trip_result.rb +3 -1
  79. data/lib/spree_cm_commissioner/version.rb +1 -1
  80. metadata +39 -4
@@ -0,0 +1,71 @@
1
+ module SpreeCmCommissioner
2
+ # Exchanges a Telegram OAuth authorization `code` for an `id_token` at
3
+ # Telegram's token endpoint. This is the server-side half of the mobile/web
4
+ # PKCE login: the client cannot call Telegram's /token directly (native apps
5
+ # have no registered HTTPS origin, and browsers are blocked by CORS), so it
6
+ # relays the exchange through us.
7
+ #
8
+ # Telegram validates the `redirect_uri` *parameter* against a BotFather-
9
+ # registered domain, not the caller's host, so this can safely run from the
10
+ # API server. This Telegram Login Widget client is confidential (it has a
11
+ # client_secret), so PKCE alone isn't enough — Telegram's /token endpoint
12
+ # still requires `client_secret`, which is why it must be injected here
13
+ # server-side rather than sent by the app.
14
+ #
15
+ # Docs: https://core.telegram.org/bots/telegram-login
16
+ class TelegramOauthTokenExchanger < BaseInteractor
17
+ TOKEN_ENDPOINT = 'https://oauth.telegram.org/token'.freeze
18
+
19
+ REQUIRED_PARAMS = %i[client_id code redirect_uri code_verifier].freeze
20
+
21
+ delegate :client_id, :code, :redirect_uri, :code_verifier, to: :context
22
+
23
+ def call
24
+ missing = REQUIRED_PARAMS.find { |key| context[key].blank? }
25
+ return context.fail!(message: "#{missing}_missing") if missing
26
+ return context.fail!(message: 'telegram_oauth_client_secret_not_configured') if client_secret.blank?
27
+
28
+ response = post_to_telegram
29
+
30
+ context.status = response.code.to_i
31
+ context.body = response.body
32
+ end
33
+
34
+ private
35
+
36
+ def client_secret
37
+ ENV['TELEGRAM_OAUTH_CLIENT_SECRET'].presence
38
+ end
39
+
40
+ def post_to_telegram
41
+ uri = URI(TOKEN_ENDPOINT)
42
+ request = Net::HTTP::Post.new(uri)
43
+ request.set_form_data(
44
+ 'client_id' => client_id,
45
+ 'client_secret' => client_secret,
46
+ 'code' => code,
47
+ 'grant_type' => 'authorization_code',
48
+ 'redirect_uri' => redirect_uri,
49
+ 'code_verifier' => code_verifier
50
+ )
51
+ # Telegram's /token endpoint also checks the caller against the client's
52
+ # registered Trusted Origins. A plain server-to-server POST sends no
53
+ # Origin header by default, so without this Telegram rejects the
54
+ # exchange with {"error":"invalid_client"} even though the auth code
55
+ # itself was issued successfully moments earlier.
56
+ request['Origin'] = origin_from(redirect_uri)
57
+
58
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
59
+ rescue StandardError => e
60
+ CmAppLogger.error(label: 'telegram_token_exchange_failed', data: e.message)
61
+ context.fail!(message: 'telegram_token_exchange_failed', error: e.message)
62
+ end
63
+
64
+ def origin_from(url)
65
+ uri = URI(url)
66
+ origin = "#{uri.scheme}://#{uri.host}"
67
+ origin += ":#{uri.port}" if uri.port && uri.port != uri.default_port
68
+ origin
69
+ end
70
+ end
71
+ end
@@ -2,11 +2,8 @@ module SpreeCmCommissioner
2
2
  class UserIdTokenAuthenticator < BaseInteractor
3
3
  # :id_token, :tenant_id (optional)
4
4
  def call
5
- context.user = if checker.user.nil?
6
- register_user
7
- else
8
- checker.user
9
- end
5
+ context.user = checker.user || register_user
6
+ context.fail!(message: I18n.t('authenticator.incorrect_login')) if context.user.nil?
10
7
 
11
8
  update_user_email if context.user.email.blank?
12
9
 
@@ -1,11 +1,16 @@
1
1
  module SpreeCmCommissioner
2
2
  class UserPasswordAuthenticator < BaseInteractor
3
- delegate :login, :password, :tenant_id, to: :context
3
+ delegate :login, :password, :login_token, :tenant_id, to: :context
4
4
 
5
5
  def call
6
+ return authenticate_with_login_token if login_token.present?
7
+
6
8
  context.user = Spree.user_class.find_user_by_login(login, tenant_id)
7
9
  context.fail!(message: I18n.t('authenticator.incorrect_login')) if context.user.nil?
8
10
 
11
+ # Load-test accounts have no usable password — they authenticate only via login_token above.
12
+ context.fail!(message: I18n.t('authenticator.incorrect_login')) if context.user.load_test_account?
13
+
9
14
  if spree_confirmable? && active_for_authentication? && !validate_password(context.user)
10
15
  context.fail!(message: I18n.t('authenticator.incorrect_password'))
11
16
  end
@@ -16,6 +21,23 @@ module SpreeCmCommissioner
16
21
 
17
22
  private
18
23
 
24
+ # Load-test accounts have no real password (see rake load_test:create_accounts) — instead they
25
+ # authenticate with a stateless, signed id_token (JWT). k6 sends it as the `id_token` param on
26
+ # grant_type=password with no username/password; UserAuthenticator routes it here as a
27
+ # login_token. The token is self-identifying, like a Google id_token: its `sub` names the
28
+ # account it logs in as, verified by signature + expiry via LoginToken.decode (no DB-stored
29
+ # secret, no email lookup). Tenant scoping is enforced afterward by
30
+ # UserAuthenticator#validate_tenant_match!.
31
+ def authenticate_with_login_token
32
+ payload = SpreeCmCommissioner::LoadTest::LoginToken.decode(login_token)
33
+ context.fail!(message: I18n.t('authenticator.incorrect_login')) if payload.nil?
34
+
35
+ user = Spree.user_class.find_by(id: payload['sub'])
36
+ context.fail!(message: I18n.t('authenticator.incorrect_login')) if user.nil? || !user.load_test_account?
37
+
38
+ context.user = user
39
+ end
40
+
19
41
  def validate_password(user)
20
42
  user.valid_for_authentication? { user.valid_password?(password) }
21
43
  end
@@ -0,0 +1,78 @@
1
+ module SpreeCmCommissioner
2
+ # Authenticates a user from a verified Telegram OIDC id_token. Resolves an
3
+ # existing user by their telegram identity (sub) or registers a new one.
4
+ # Mirrors the inline identity handling of UserTelegramWebAppAuthenticator so
5
+ # that both Telegram login flows resolve to the same UserIdentityProvider.
6
+ class UserTelegramOauthAuthenticator < BaseInteractor
7
+ delegate :id_token, :tenant_id, to: :context
8
+
9
+ # :id_token, :tenant_id (optional)
10
+ def call
11
+ context.fail!(message: 'telegram_id_token_missing') if id_token.blank?
12
+
13
+ provider = verified_provider
14
+ context.user = find_or_register_user(provider)
15
+
16
+ context.fail!(message: 'account_temporarily_deleted') if context.user.soft_deleted?
17
+ end
18
+
19
+ private
20
+
21
+ def verified_provider
22
+ result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token)
23
+ context.fail!(message: result.message) unless result.success?
24
+
25
+ result.provider
26
+ end
27
+
28
+ def find_or_register_user(provider)
29
+ identity_checker = SpreeCmCommissioner::UserIdentityChecker.call(
30
+ identity_type: provider[:identity_type],
31
+ sub: provider[:sub],
32
+ tenant_id: tenant_id
33
+ )
34
+ return identity_checker.user if identity_checker.success?
35
+
36
+ register_user(provider)
37
+ end
38
+
39
+ def register_user(provider)
40
+ ActiveRecord::Base.transaction do
41
+ user = build_user(provider)
42
+ user.save!
43
+ link_identity!(user, provider)
44
+ user
45
+ end
46
+ end
47
+
48
+ def build_user(provider)
49
+ user = Spree.user_class.new(
50
+ email: provider[:email],
51
+ tenant_id: tenant_id,
52
+ password: SecureRandom.base64(16),
53
+ **name_attributes(provider[:name])
54
+ )
55
+
56
+ # Skip presence validation for email/login/phone_number for social signups.
57
+ user.registering_via_social_network = true
58
+ user.confirmed_at = Time.zone.now
59
+ user
60
+ end
61
+
62
+ def link_identity!(user, provider)
63
+ user.user_identity_providers.create!(
64
+ identity_type: :telegram,
65
+ sub: provider[:sub],
66
+ name: provider[:username].presence || provider[:name]
67
+ )
68
+ end
69
+
70
+ def name_attributes(name)
71
+ full_name = name.to_s.strip
72
+ return {} if full_name.blank?
73
+
74
+ parts = full_name.split
75
+ { first_name: parts[0], last_name: parts[1..].join(' ').presence }.compact
76
+ end
77
+ end
78
+ end
@@ -25,6 +25,7 @@ module Spree
25
25
  def confirm_email(order, resend: false)
26
26
  @order = order.respond_to?(:id) ? order : Spree::Order.find(order)
27
27
  return false if @order.email.blank?
28
+ return false if @order.products.all?(&:vote_package?) # disable confirmation email for vote package orders
28
29
 
29
30
  setup_tenant_and_store
30
31
  if @tenant.present?
@@ -36,6 +36,23 @@ module SpreeCmCommissioner
36
36
  guest_id_card
37
37
  ].freeze
38
38
 
39
+ # Maps each KYC bit field to the actual Guest attribute(s) it stores.
40
+ KYC_FIELD_ATTRIBUTES = {
41
+ guest_name: %i[first_name last_name],
42
+ guest_gender: %i[gender],
43
+ guest_dob: %i[dob],
44
+ guest_occupation: %i[occupation_id other_occupation],
45
+ guest_nationality: %i[nationality_id],
46
+ guest_age: %i[age],
47
+ guest_emergency_contact: %i[emergency_contact],
48
+ guest_organization: %i[other_organization],
49
+ guest_expectation: %i[expectation],
50
+ guest_social_contact: %i[social_contact_platform social_contact],
51
+ guest_address: %i[address],
52
+ guest_phone_number: %i[phone_number],
53
+ guest_contact: %i[contact]
54
+ }.freeze
55
+
39
56
  def kyc? = kyc != 0
40
57
 
41
58
  BIT_FIELDS.each do |field, bit_value|
@@ -19,15 +19,17 @@ module SpreeCmCommissioner
19
19
  scope :filter_by_event, lambda { |event|
20
20
  case event
21
21
  when 'upcoming'
22
- complete_or_canceled.paid.where.not(to_date: nil).where('to_date >= ?', Time.zone.now)
22
+ complete_or_canceled.paid.where.not(to_date: nil).where.not(product_type: 'vote_package')
23
+ .where('to_date >= ?', Time.zone.now)
23
24
  .joins(:order).where(spree_orders: { canceled_at: nil })
24
25
 
25
26
  when 'complete'
26
- complete_or_canceled.paid.where.not(to_date: nil).where('to_date < ?', Time.zone.now)
27
+ complete_or_canceled.paid.where.not(to_date: nil).where.not(product_type: 'vote_package')
28
+ .where('to_date < ?', Time.zone.now)
27
29
  .joins(:order).where(spree_orders: { canceled_at: nil })
28
30
 
29
31
  when 'canceled'
30
- canceled
32
+ canceled.where.not(product_type: 'vote_package')
31
33
  else
32
34
  none
33
35
  end
@@ -21,6 +21,8 @@ module SpreeCmCommissioner
21
21
 
22
22
  state_machine.after_transition to: :complete, do: :mark_user_as_has_incomplete_guest_info, if: :has_incomplete_guest_info?
23
23
  state_machine.after_transition to: :complete, do: :send_transaction_email_to_user, if: :user_has_email?
24
+
25
+ state_machine.before_transition to: :resumed, do: :clear_cancellation_details
24
26
  state_machine.after_transition to: :resumed, do: :precalculate_conversion
25
27
  state_machine.around_transition to: :resumed, do: :unstock_inventory!
26
28
 
@@ -241,6 +243,7 @@ module SpreeCmCommissioner
241
243
  def send_transaction_email_to_user
242
244
  line_items.each do |line_item|
243
245
  next if line_item.event.nil?
246
+ next unless line_item.enable_social_sharing_email?
244
247
 
245
248
  SpreeCmCommissioner::EventTransactionalMailer.send_to_participant(line_item.event_id, user.id).deliver_later
246
249
  end
@@ -370,5 +373,13 @@ module SpreeCmCommissioner
370
373
  def de_allocate_resources
371
374
  SpreeCmCommissioner::VotingCreditDeAllocationJob.perform_later(order_id: id)
372
375
  end
376
+
377
+ # Reset the cancellation info when an order is resumed.
378
+ # We only assign the values here; the resume transition already saves the order,
379
+ # so they get saved together with it (one validated save, no update_columns).
380
+ def clear_cancellation_details
381
+ self.canceled_at = nil
382
+ self.canceler_id = nil
383
+ end
373
384
  end
374
385
  end
@@ -13,6 +13,8 @@ module SpreeCmCommissioner
13
13
  :required_self_check_in_location?,
14
14
  :enable_telegram_alert,
15
15
  :enable_telegram_alert?,
16
+ :enable_social_sharing_email,
17
+ :enable_social_sharing_email?,
16
18
  to: :product
17
19
  end
18
20
  end
@@ -18,6 +18,31 @@ module SpreeCmCommissioner
18
18
  (discontinue_on.nil? || discontinue_on >= current_time)
19
19
  end
20
20
 
21
+ # This constant help preview sections to get the relatable class from the previewable_type of the preview role.
22
+ RELATABLE_CLASS_BY_PREVIEWABLE_TYPE = {
23
+ 'Spree::Taxon' => 'Spree::Taxon',
24
+ 'SpreeCmCommissioner::Show' => 'Spree::Taxon',
25
+ 'Spree::Product' => 'Spree::Product'
26
+ }.freeze
27
+
28
+ # homepage_section ids reachable through a user's preview roles
29
+ def self.section_ids_previewable_by(user)
30
+ # get previewable_id/previewable_type from the user's preview roles,
31
+ # eg: [[798, "SpreeCmCommissioner::Show"], [245, "Spree::Product"], [449, "Spree::Taxon"]
32
+ # Then batch the ids by relatable_type so we run one query per type instead of one per row
33
+ # eg. {"Spree::Taxon" => [798, 449], "Spree::Product" => [245]}
34
+ ids_by_relatable_type = user.preview_roles.pluck(:previewable_id, :previewable_type)
35
+ .each_with_object(Hash.new { |h, k| h[k] = [] }) do |(id, previewable_type), acc|
36
+ relatable_type = RELATABLE_CLASS_BY_PREVIEWABLE_TYPE[previewable_type]
37
+ acc[relatable_type] << id if relatable_type
38
+ end
39
+
40
+ # find homepage_section_relatables matching those ids/types, then return the unique homepage_section ids
41
+ ids_by_relatable_type.flat_map do |relatable_type, ids|
42
+ where(relatable_type: relatable_type, relatable_id: ids).pluck(:homepage_section_id)
43
+ end.uniq
44
+ end
45
+
21
46
  private
22
47
 
23
48
  def update_homepage_section
@@ -71,12 +71,6 @@ module SpreeCmCommissioner
71
71
  end
72
72
  end
73
73
 
74
- # override
75
- def after_resume
76
- super
77
- update_columns(canceled_at: nil, canceler_id: nil) # rubocop:disable Rails/SkipsModelValidations
78
- end
79
-
80
74
  # override to allow storing cancellation reason in one DB write
81
75
  def canceled_by(user, cancellation_reason: nil)
82
76
  transaction do
@@ -5,6 +5,8 @@ module SpreeCmCommissioner
5
5
  def self.prepended(base)
6
6
  base.const_set(:DISPLAY, DISPLAY)
7
7
 
8
+ base.whitelisted_ransackable_attributes = %w[name]
9
+
8
10
  # Expose a dedicated class helper so services can call Spree::PaymentMethod.ticket_transfer
9
11
  # instead of duplicating lookup/creation logic.
10
12
  base.define_singleton_method(:ticket_transfer) do
@@ -1,10 +1,18 @@
1
1
  module SpreeCmCommissioner
2
+ # A pricing-model assignment. A row is either:
3
+ # - route-scoped: trip_id NULL, keyed by route + O-D (unchanged legacy behaviour), or
4
+ # - trip-scoped: trip_id set, keyed by that specific trip + its own O-D (overrides route pricing
5
+ # for that trip only).
2
6
  class PricingModelRoute < Base
3
7
  belongs_to :route, class_name: 'SpreeCmCommissioner::Route'
8
+ belongs_to :trip, class_name: 'SpreeCmCommissioner::Trip', optional: true
4
9
  belongs_to :pricing_model, class_name: 'SpreeCmCommissioner::PricingModel'
5
10
  belongs_to :origin_place, class_name: 'SpreeCmCommissioner::Place'
6
11
  belongs_to :destination_place, class_name: 'SpreeCmCommissioner::Place'
7
12
 
13
+ scope :route_scoped, -> { where(trip_id: nil) }
14
+ scope :trip_scoped, -> { where.not(trip_id: nil) }
15
+
8
16
  scope :for_origin_destination, lambda { |origin_place_id, destination_place_id|
9
17
  where(origin_place_id: origin_place_id, destination_place_id: destination_place_id)
10
18
  }
@@ -134,6 +134,7 @@ module SpreeCmCommissioner
134
134
  base.store_public_metadata :enable_inventory_hold, :boolean, default: false
135
135
  base.store_public_metadata :enable_telegram_alert, :boolean, default: true
136
136
  base.store_public_metadata :enable_send_receipt, :boolean, default: true
137
+ base.store_public_metadata :enable_social_sharing_email, :boolean, default: false
137
138
 
138
139
  base.store_private_metadata :advertise_weight, :integer, default: 1
139
140
  base.store_private_metadata :video_url, :string
@@ -22,6 +22,8 @@ module SpreeCmCommissioner
22
22
  # before create, duplicate seat layout from vehicle_type if present
23
23
  before_validation :duplicate_seat_layout_from_vehicle, if: -> { seat_layout.nil? && vehicle_type&.seat_layout.present? }
24
24
 
25
+ before_validation :set_name_and_short_name_from_product, on: :create
26
+
25
27
  after_create :increment_route_metric_trip_count
26
28
  after_create :increment_vendor_route_metric_trip_count
27
29
 
@@ -52,6 +54,9 @@ module SpreeCmCommissioner
52
54
  has_many :trip_blazer_queries, as: :queryable, class_name: 'SpreeCmCommissioner::BlazerQueryable'
53
55
  has_many :blazer_queries, through: :trip_blazer_queries, source: :blazer_query, class_name: 'Blazer::Query'
54
56
 
57
+ has_many :pricing_model_routes, class_name: 'SpreeCmCommissioner::PricingModelRoute', dependent: :destroy
58
+ has_many :pricing_models, through: :pricing_model_routes, class_name: 'SpreeCmCommissioner::PricingModel'
59
+
55
60
  has_many :trip_stops, class_name: 'SpreeCmCommissioner::TripStop', dependent: :destroy
56
61
  has_many :boarding_trip_stops, -> { boarding.order('departure_time ASC NULLS LAST, id ASC') }, class_name: 'SpreeCmCommissioner::TripStop'
57
62
  has_many :drop_off_trip_stops, -> { drop_off.order('arrival_time ASC NULLS LAST') }, class_name: 'SpreeCmCommissioner::TripStop'
@@ -65,6 +70,7 @@ module SpreeCmCommissioner
65
70
  has_many :inventory_items, through: :variants
66
71
  has_many :blocks, through: :variants
67
72
 
73
+ validates :name, presence: true
68
74
  validates :departure_time, presence: true
69
75
  validates :duration, numericality: { greater_than: 0 }
70
76
 
@@ -120,7 +126,7 @@ module SpreeCmCommissioner
120
126
  end
121
127
 
122
128
  def display_name
123
- product&.name
129
+ self[:name]
124
130
  end
125
131
 
126
132
  def featured?
@@ -129,6 +135,12 @@ module SpreeCmCommissioner
129
135
 
130
136
  private
131
137
 
138
+ # Snapshot product names on trip creation. Runs before validation
139
+ def set_name_and_short_name_from_product
140
+ self[:name] ||= product&.name
141
+ self[:short_name] ||= product&.short_name
142
+ end
143
+
132
144
  def resolve_service_origin_id
133
145
  route&.service_origin_id || vendor&.service_origin_id
134
146
  end
@@ -60,6 +60,12 @@ module SpreeCmCommissioner
60
60
  base.store_public_metadata :qr_data_invalidated_at, :datetime
61
61
  base.store_public_metadata :branch_ids, :array, default: []
62
62
 
63
+ # Private - marks accounts created by rake load_test:create_accounts. Real passwords never
64
+ # work for these accounts (see UserPasswordAuthenticator) — login is only possible by sending
65
+ # a stateless, signed JWT (minted on demand from the admin UI, see LoadTest::LoginToken) as
66
+ # the id_token param. Nothing token-related is stored on the account itself.
67
+ base.store_private_metadata :load_test_account, :boolean, default: false
68
+
63
69
  # Validations
64
70
  base.validates :primary_service_type,
65
71
  inclusion: { in: SpreeCmCommissioner::ServiceType::SERVICE_TYPES.map(&:to_s) },
@@ -0,0 +1,3 @@
1
+ <!-- insert_after ".table-responsive" -->
2
+
3
+ <%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @payment_methods } %>
@@ -0,0 +1,4 @@
1
+ <!-- insert_after "[data-hook='admin_payment_methods_index_search']" -->
2
+
3
+ <%# keep the current tab when the search form submits %>
4
+ <%= hidden_field_tag :tab, params[:tab] if params[:tab].present? %>
@@ -0,0 +1,13 @@
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
+
3
+ <div>
4
+ <%= f.field_container :enable_social_sharing_email do %>
5
+ <%= f.label :enable_social_sharing_email do %>
6
+ <%= f.check_box :enable_social_sharing_email %>
7
+ <strong><%= Spree.t(:enable_social_sharing_email) %></strong>
8
+ <% end %>
9
+ <small class="form-text text-muted">
10
+ When enabled, participants receive a transaction confirmation email after their order for this event is completed.
11
+ </small>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- insert_after "erb[loud]:contains('link_to_delete(variant, no_text: true)')" -->
2
+
3
+ <% if can?(:edit, variant) && !variant.deleted? %>
4
+ <%= link_to fixture_admin_product_variant_path(@product, variant),
5
+ class: 'btn btn-light btn-sm with-tip icon-link',
6
+ data: {title: Spree.t(:fixture, default: 'Fixture') } do %>
7
+ <%= svg_icon name: "clipboard-data.svg", width: '12', height: '12' %>
8
+ <% end %>
9
+ <% end %>
@@ -245,6 +245,8 @@ module SpreeCmCommissioner
245
245
  def build_trip_result(trip:, trip_row:, parent_trip:)
246
246
  SpreeCmCommissioner::TripResult.new(
247
247
  id: trip.id,
248
+ name: trip.display_name,
249
+ short_name: trip.short_name,
248
250
  departure_time: trip.departure_time,
249
251
  duration: trip.duration,
250
252
  distance: trip.distance,
@@ -15,8 +15,8 @@ module SpreeCmCommissioner
15
15
  expires_in: 30.minutes
16
16
  )
17
17
 
18
- attributes :origin_place, :destination_place, :stops, :price, :compare_at_price, :currency, :quantity_available, :max_capacity,
19
- :allow_seat_selection, :departure_time, :route_type, :distance, :offset_days,
18
+ attributes :name, :short_name, :origin_place, :destination_place, :stops, :price, :compare_at_price, :currency, :quantity_available,
19
+ :max_capacity, :allow_seat_selection, :departure_time, :route_type, :distance, :offset_days,
20
20
  :featured_until, :display_price, :display_compare_at_price,
21
21
  :open_dated_product_id, :open_dated_price, :open_dated_compare_at_price
22
22
 
@@ -2,7 +2,9 @@ module SpreeCmCommissioner
2
2
  module V2
3
3
  module Storefront
4
4
  class TripSerializer < BaseSerializer
5
- attributes :departure_time, :arrival_time, :duration_in_hms, :route_type, :allow_seat_selection
5
+ attributes :departure_time, :arrival_time, :duration_in_hms, :route_type, :allow_seat_selection, :short_name
6
+
7
+ attribute :name, &:display_name
6
8
 
7
9
  has_one :vehicle_type, serializer: ::SpreeCmCommissioner::V2::Storefront::TripVehicleTypeSerializer
8
10
  has_one :seat_layout, serializer: SpreeCmCommissioner::V2::Storefront::SeatLayoutSerializer
@@ -0,0 +1,86 @@
1
+ # SpreeCmCommissioner::DraftOrder::Create builds a fresh order from a locally-assembled
2
+ # list of line items in a single request, then advances it to `address` to hold inventory
3
+ # where the product type requires it.
4
+ #
5
+ # This is the generic counterpart of SpreeCmCommissioner::TransitOrder::Create: instead of
6
+ # transit legs/seats it takes plain `line_items` params, so the app can browse/add entirely
7
+ # offline and only touch the server once, at Continue.
8
+ #
9
+ # Each line item is built the same way Spree::Cart::AddItem builds one: `options` is filtered
10
+ # down to Spree::PermittedAttributes.line_item_attributes and passed through `options:` to
11
+ # `order.line_items.new`, which routes it through LineItem#options= (`update_price_from_modifier`)
12
+ # — the same price computation the regular add-to-cart path uses, including per-date pricing for
13
+ # permanent-stock variants (see LineItemDecorator#update_price_from_modifier /
14
+ # Variant#price_for_date). guests_attributes rides through the same way, via the standard
15
+ # `accepts_nested_attributes_for :guests` writer.
16
+ #
17
+ # Attributes:
18
+ # - line_items: [Array<Hash>] each { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} }
19
+ # options may carry from_date/to_date (service/permanent-stock products) and guests_attributes
20
+ # (seat-selection products, [{ block_id: }, ...])
21
+ # - user: [Spree::User] the user for whom the order is being created (nil for guest checkout)
22
+ # - store: [Spree::Store] the current store (defaults to Spree::Store.default when nil)
23
+ # - currency: [String] the order currency (defaults to the store currency when nil)
24
+ # - tenant: [SpreeCmCommissioner::Tenant] the tenant the order is created under (nil outside tenant scope)
25
+ module SpreeCmCommissioner
26
+ module DraftOrder
27
+ class Create
28
+ prepend ::Spree::ServiceModule::Base
29
+
30
+ def call(line_items:, user: nil, store: nil, currency: nil, tenant: nil)
31
+ return failure(nil, I18n.t('spree_cm_commissioner.draft_order.line_items_missing')) if line_items.blank?
32
+
33
+ order = create_order!(line_items, user, store, currency, tenant)
34
+ success(order: order)
35
+ rescue StandardError => e
36
+ failure(nil, e.message)
37
+ end
38
+
39
+ def create_order!(line_items_params, user, store, currency, tenant)
40
+ store ||= Spree::Store.default
41
+ currency ||= store&.default_currency
42
+ order = Spree::Order.new(state: 'cart', use_billing: true, user: user, store: store, currency: currency, tenant: tenant)
43
+ line_items_params.each { |line_item_params| build_line_item!(order, store, line_item_params) }
44
+
45
+ ActiveRecord::Base.transaction do
46
+ raise StandardError, order_error_message(order) unless order.save
47
+
48
+ order.update_with_updater!
49
+
50
+ # Build blank guests per line item so the client can render guest fields offline.
51
+ order.line_items.each(&:generate_remaining_guests)
52
+
53
+ raise StandardError, order_error_message(order) if order.has_checkout_step?('address') && !order.next
54
+ end
55
+
56
+ order
57
+ end
58
+
59
+ # Builds (unsaved) an order.line_items member — persisted together with the order via
60
+ # order.save's cascading autosave of new has_many records, same as TransitOrder::Create.
61
+ def build_line_item!(order, store, line_item_params)
62
+ variant = store.variants.find(line_item_params[:variant_id])
63
+ options = (line_item_params[:options] || {}).to_h.with_indifferent_access
64
+ permitted_opts = ::Spree::PermittedAttributes.line_item_attributes.flatten.index_with { |attribute| options[attribute] }
65
+ opts = permitted_opts.merge(currency: order.currency).compact
66
+
67
+ order.line_items.new(
68
+ variant: variant,
69
+ quantity: line_item_params[:quantity],
70
+ options: opts,
71
+ public_metadata: (line_item_params[:public_metadata] || {}).to_h,
72
+ private_metadata: (line_item_params[:private_metadata] || {}).to_h
73
+ )
74
+ end
75
+
76
+ # order.errors already includes line item errors (accepts_nested_attributes_for autosaves them
77
+ # in). Skip full_message's attribute prefix when the validator already wrote a full sentence,
78
+ # e.g. before: "Quantity Not enough stock" -> after: "Not enough stock".
79
+ def order_error_message(order)
80
+ order.errors.map do |error|
81
+ error.raw_type.is_a?(String) || error.options[:message].is_a?(String) ? error.message : error.full_message
82
+ end.uniq.to_sentence
83
+ end
84
+ end
85
+ end
86
+ end