spree_cm_commissioner 2.9.1.pre.pre4 → 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 (74) 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/product_decorator.rb +1 -0
  35. data/app/models/spree_cm_commissioner/trip.rb +10 -1
  36. data/app/models/spree_cm_commissioner/user_decorator.rb +6 -0
  37. data/app/overrides/spree/admin/payment_methods/index/pagination.html.erb.deface +3 -0
  38. data/app/overrides/spree/admin/payment_methods/index/preserve_tab_in_search.html.erb.deface +4 -0
  39. data/app/overrides/spree/admin/products/_form/enable_social_sharing_email.html.erb.deface +13 -0
  40. data/app/overrides/spree/admin/variants/index/fixture_action.html.erb.deface +9 -0
  41. data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +2 -0
  42. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -2
  43. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +3 -1
  44. data/app/services/spree_cm_commissioner/draft_order/create.rb +86 -0
  45. data/app/services/spree_cm_commissioner/load_test/create_accounts.rb +68 -0
  46. data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +50 -0
  47. data/app/services/spree_cm_commissioner/load_test/flow_registry.rb +26 -0
  48. data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +140 -0
  49. data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +178 -0
  50. data/app/services/spree_cm_commissioner/load_test/login_token.rb +46 -0
  51. data/app/services/spree_cm_commissioner/order/guests/update.rb +56 -0
  52. data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +6 -1
  53. data/app/services/spree_cm_commissioner/user_authenticator.rb +22 -1
  54. data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +13 -2
  55. data/app/views/spree/admin/featured_trips/index.html.erb +1 -1
  56. data/app/views/spree/admin/guests/_form.html.erb +37 -0
  57. data/app/views/spree/admin/shared/_system_tabs.html.erb +7 -0
  58. data/app/views/spree/admin/system/load_test_accounts/_login_tokens_modal.html.erb +52 -0
  59. data/app/views/spree/admin/system/load_test_accounts/index.html.erb +175 -0
  60. data/app/views/spree/admin/system/load_test_flows/index.html.erb +66 -0
  61. data/app/views/spree/admin/system/load_testing/index.html.erb +25 -0
  62. data/app/views/spree/admin/variants/fixture.html.erb +49 -0
  63. data/config/initializers/doorkeeper.rb +10 -6
  64. data/config/initializers/spree_permitted_attributes.rb +30 -0
  65. data/config/load_test_flows/platform.yml +25 -0
  66. data/config/load_test_flows/tenant.yml +46 -0
  67. data/config/locales/en.yml +94 -0
  68. data/config/locales/km.yml +2 -0
  69. data/config/routes.rb +19 -0
  70. data/db/migrate/20260703065239_add_position_to_cm_voting_contestants.rb +1 -1
  71. data/db/migrate/20260708150000_add_name_and_short_name_to_cm_trips.rb +6 -0
  72. data/lib/spree_cm_commissioner/trip_result.rb +3 -1
  73. data/lib/spree_cm_commissioner/version.rb +1 -1
  74. metadata +37 -4
@@ -0,0 +1,50 @@
1
+ module SpreeCmCommissioner
2
+ module LoadTest
3
+ # Soft-deletes up to N load-test accounts for a scope (acts_as_paranoid). Since accounts now
4
+ # get random emails (see LoadTest::CreateAccounts), they can't be found by reconstructing a
5
+ # deterministic email — they're located by the `load_test_account` flag + tenant instead, the
6
+ # same way the admin UI lists them. Not needed on staging — accounts are reusable across runs.
7
+ class DestroyAccounts
8
+ prepend ::Spree::ServiceModule::Base
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
+ def call(client_id: nil, count: 20, **_ignored)
15
+ tenant = resolve_tenant(client_id)
16
+
17
+ accounts = load_test_accounts(tenant).first(count)
18
+ accounts.each(&:destroy!)
19
+
20
+ success(destroyed: accounts.size, not_found: [count - accounts.size, 0].max)
21
+ rescue StandardError => e
22
+ failure(nil, e.message)
23
+ end
24
+
25
+ private
26
+
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).
30
+ def load_test_accounts(tenant)
31
+ Spree::User.where(tenant_id: tenant&.id)
32
+ .order(:id)
33
+ .limit(SCAN_LIMIT)
34
+ .select(&:load_test_account?)
35
+ end
36
+
37
+ def resolve_tenant(client_id)
38
+ return nil if client_id.blank?
39
+
40
+ application = Spree::OauthApplication.find_by(uid: client_id)
41
+ raise "No OAuth application found for CLIENT_ID=#{client_id}" unless application
42
+
43
+ tenant = application.tenant
44
+ raise "OAuth application #{client_id} has no tenant associated" unless tenant
45
+
46
+ tenant
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ module SpreeCmCommissioner
2
+ module LoadTest
3
+ # Reads config/load_test_flows/{platform,tenant}.yml — the registry of k6 flows
4
+ # (../../../../load_testing) and where to find real data for each one. Purely descriptive:
5
+ # nothing here queries the DB or generates JSON, it just surfaces the YAML for the
6
+ # "Test Data Sources" card on the admin/organizer Load Testing pages. See those files'
7
+ # header comments for the field shapes.
8
+ module FlowRegistry
9
+ YAML_DIR = File.join(SpreeCmCommissioner::Engine.root, 'config', 'load_test_flows')
10
+ YAML_PATHS = {
11
+ 'platform' => File.join(YAML_DIR, 'platform.yml'),
12
+ 'tenant' => File.join(YAML_DIR, 'tenant.yml')
13
+ }.freeze
14
+
15
+ # workflow: 'platform' or 'tenant'. Returns { flow_name => attributes } in file order.
16
+ def self.for_workflow(workflow)
17
+ all.fetch(workflow.to_s, {})
18
+ end
19
+
20
+ def self.all
21
+ @all ||= YAML_PATHS.transform_values { |path| YAML.load_file(path) }
22
+ .deep_symbolize_keys.transform_keys(&:to_s)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,140 @@
1
+ module SpreeCmCommissioner
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.
7
+ #
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).
11
+ #
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.
16
+ class GenerateBookingTicketData
17
+ prepend ::Spree::ServiceModule::Base
18
+
19
+ def call(variant:)
20
+ product = variant.product
21
+ event = product.event
22
+ issues = issues_for(variant, event)
23
+
24
+ data = {
25
+ event_permalink: event&.permalink,
26
+ product_slug: product.slug,
27
+ 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
+ }
35
+ }
36
+
37
+ success(data: data, issues: issues)
38
+ rescue StandardError => e
39
+ failure(nil, e.message)
40
+ end
41
+
42
+ private
43
+
44
+ # Each check returns an explanatory string when it would break the booking flow, or nil when the
45
+ # variant is fine on that axis. Order mirrors the flow: availability first, then checkout shape.
46
+ def issues_for(variant, event)
47
+ product = variant.product
48
+
49
+ [
50
+ inactive_product_issue(product),
51
+ discontinued_variant_issue(variant),
52
+ permanent_stock_issue(variant),
53
+ request_to_book_issue(variant),
54
+ out_of_stock_issue(variant),
55
+ seat_selection_issue(variant, product),
56
+ max_quantity_issue(variant),
57
+ missing_event_issue(event)
58
+ ].compact
59
+ end
60
+
61
+ # add-to-cart runs SpreeCmCommissioner::Stock::AvailabilityChecker#can_supply?, which returns false
62
+ # the moment variant.available? is false — before it ever looks at stock. available? is
63
+ # !discontinued? && product.available? (active? && !deleted?), none of which purchasable?
64
+ # (in_stock? || backorderable?) checks. So an inactive product with stock still reports
65
+ # purchasable? == true yet fails add-to-cart with only_active_products_can_be_added_to_cart.
66
+ def inactive_product_issue(product)
67
+ return if product.available?
68
+
69
+ 'Product is not active (status is draft/archived, or the product is deleted).'
70
+ end
71
+
72
+ # Reported independently of the inactive-product check so a product that is both archived and
73
+ # discontinued surfaces both problems at once, rather than hiding one behind the other.
74
+ def discontinued_variant_issue(variant)
75
+ return unless variant.discontinued?
76
+
77
+ 'Variant is discontinued (discontinue_on is in the past, on the variant or its product).'
78
+ end
79
+
80
+ # accommodation/service/transit variants hold date-scoped inventory: the availability check needs
81
+ # from/to dates to find any inventory item, but the booking flow adds a ticket with no date, so
82
+ # cached_inventory_items comes back empty and add-to-cart fails. This flow is for event tickets.
83
+ def permanent_stock_issue(variant)
84
+ return unless variant.permanent_stock?
85
+
86
+ 'Product is a date-based (permanent-stock) type — accommodation / service / transit.'
87
+ end
88
+
89
+ # request-to-book skips payment (payment_required? is false) and "complete" diverts the order to a
90
+ # request state instead of a paid order — the flow's cash payment + complete no longer represent a
91
+ # real purchase.
92
+ def request_to_book_issue(variant)
93
+ return unless variant.need_confirmation?
94
+
95
+ 'Variant is request-to-book (need_confirmation).'
96
+ end
97
+
98
+ # Reported on its own axis (stock), independent of whether the product is active — an inactive AND
99
+ # empty variant should surface both. Only skipped for permanent-stock and request-to-book variants,
100
+ # where purchasable? legitimately reads false while the item is still bookable, so this would be a
101
+ # false alarm rather than a real problem.
102
+ def out_of_stock_issue(variant)
103
+ return if variant.permanent_stock? || variant.need_confirmation?
104
+ return if variant.purchasable?
105
+
106
+ 'Variant is out of stock (not in stock and not backorderable).'
107
+ end
108
+
109
+ def seat_selection_issue(variant, product)
110
+ return unless seat_selection_required?(variant, product)
111
+
112
+ 'Variant requires seat selection (it has seat blocks).'
113
+ end
114
+
115
+ def max_quantity_issue(variant)
116
+ max = variant.max_quantity_per_order
117
+ return if max.blank? || max >= 1
118
+
119
+ "Variant max-quantity-per-order is #{max} (less than one ticket)."
120
+ end
121
+
122
+ def missing_event_issue(event)
123
+ return unless event.nil?
124
+
125
+ 'Product has no event taxon — event_permalink is blank.'
126
+ end
127
+
128
+ # Reproduces LineItem#set_required_seat_selection_flag: a ticket needs a seat/block when its
129
+ # variant has seat blocks, gated by the trip's allow_seat_selection when the product is a trip.
130
+ # The load-test flow never picks a seat, so any such variant fails the ensure_all_guests_have_blocks
131
+ # validation at add-to-cart.
132
+ def seat_selection_required?(variant, product)
133
+ return false unless variant.blocks.any?
134
+ return product.trip.allow_seat_selection if product.trip.present?
135
+
136
+ true
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,178 @@
1
+ module SpreeCmCommissioner
2
+ module LoadTest
3
+ # Generates a load-test fixture for one of the tenant voting-related k6 flows, scoped to a
4
+ # single voting session, so it can be copied into load_testing/config/fixture/tenant/.
5
+ # An organizer opens a voting session, picks a flow (voting_flow / aba_topup_vote_flow /
6
+ # cash_on_topup_vote_flow), and this resolves show_slug + voting_session_id straight off the
7
+ # models, plus (for the topup flows) the tenant's active payment method for that gateway.
8
+ #
9
+ # Mirrors GenerateBookingTicketData's shape: success({ data:, issues: }). `issues` lists why
10
+ # the flow isn't ready to load-test (e.g. no ABA payment method configured yet); the same
11
+ # issues are duplicated into data[:metadata] so they travel with the copied JSON.
12
+ class GenerateVotingFixtureData
13
+ prepend ::Spree::ServiceModule::Base
14
+
15
+ FLOWS = %w[voting_flow aba_topup_vote_flow cash_on_topup_vote_flow].freeze
16
+
17
+ # ABA's checkout endpoint is fixed regardless of tenant/environment — only `host` (sandbox
18
+ # vs production) differs, and that comes off the payment method. See
19
+ # Vpago::PaywayV2::Checkout#checkout_url in gems/spree_vpago.
20
+ PAYWAY_CHECKOUT_PATH = '/api/payment-gateway/v1/payments/purchase'.freeze
21
+
22
+ def call(voting_session:, flow:)
23
+ unless FLOWS.include?(flow)
24
+ raise ArgumentError, I18n.t('load_test.generate_voting_fixture_data.unknown_flow', flow: flow, flows: FLOWS.join(', '))
25
+ end
26
+
27
+ show = voting_session.show
28
+ issues = []
29
+ issues << I18n.t('load_test.generate_voting_fixture_data.no_show') if show.nil?
30
+
31
+ data = { show_slug: show&.slug, voting_session_id: voting_session.id }
32
+
33
+ if flow == 'voting_flow'
34
+ issues.concat(voting_readiness_issues(voting_session))
35
+ else
36
+ issues.concat(topup_flow_issues(flow, voting_session, data))
37
+ end
38
+
39
+ data[:metadata] = { ready: issues.empty?, issues: issues }
40
+
41
+ success(data: data, issues: issues)
42
+ rescue StandardError => e
43
+ failure(nil, e.message)
44
+ end
45
+
46
+ private
47
+
48
+ # Resolves the payment method + (for ABA) its payway payload into `data`, and returns the
49
+ # readiness issues for whichever topup flow this is.
50
+ def topup_flow_issues(flow, voting_session, data)
51
+ issues = []
52
+ payment_method = resolve_payment_method(voting_session.vendor, flow)
53
+
54
+ if payment_method.nil?
55
+ issues << I18n.t('load_test.generate_voting_fixture_data.no_payment_method', label: payment_method_label(flow))
56
+ else
57
+ data[:payment_method_id] = payment_method.id
58
+ issues.concat(aba_payment_method_issues(payment_method, data)) if flow == 'aba_topup_vote_flow'
59
+ end
60
+
61
+ issues.concat(vote_package_issues(voting_session))
62
+ end
63
+
64
+ def aba_payment_method_issues(payment_method, data)
65
+ data[:payway] = payway_payload(payment_method)
66
+
67
+ return [] if payment_method.preferred_host.to_s.match?(/sandbox/i)
68
+
69
+ [
70
+ I18n.t('load_test.generate_voting_fixture_data.sandbox_host_mismatch', host: payment_method.preferred_host.inspect)
71
+ ]
72
+ end
73
+
74
+ # Mirrors VotingSession#can_vote? (enabled? && now.between?(opens_at, closes_at) &&
75
+ # !manual_advance?) — the actual gate VoteProcessor#validate! applies to POST votes.
76
+ # Reports only the sub-condition(s) that are actually failing, in plain sentences, rather
77
+ # than dumping the whole rule regardless of which part broke it.
78
+ def can_vote_issues(voting_session)
79
+ issues = []
80
+
81
+ unless voting_session.enabled?
82
+ issues << I18n.t('load_test.generate_voting_fixture_data.session_status_invalid', status: voting_session.status.inspect)
83
+ end
84
+
85
+ issues << I18n.t('load_test.generate_voting_fixture_data.manual_advance_never_votes') if voting_session.manual_advance?
86
+ issues << voting_window_issue(voting_session) unless Time.zone.now.between?(voting_session.opens_at, voting_session.closes_at)
87
+
88
+ issues
89
+ end
90
+
91
+ def voting_window_issue(voting_session)
92
+ now = Time.zone.now
93
+
94
+ if now < voting_session.opens_at
95
+ I18n.t('load_test.generate_voting_fixture_data.voting_not_opened', opens_at: voting_session.opens_at)
96
+ else
97
+ I18n.t('load_test.generate_voting_fixture_data.voting_already_closed', closes_at: voting_session.closes_at)
98
+ end
99
+ end
100
+
101
+ # Mirrors VoteProcessor#validate! (votes_controller's actual gate on POST .../votes) and
102
+ # VotingContestantsController#index (returns [] with no status filter, which the flow's
103
+ # random contestant pick can't survive).
104
+ def voting_readiness_issues(voting_session)
105
+ issues = can_vote_issues(voting_session)
106
+
107
+ issues << I18n.t('load_test.generate_voting_fixture_data.no_contestants') if voting_session.voting_contestants.empty?
108
+
109
+ issues << I18n.t('load_test.generate_voting_fixture_data.no_eligible_credit') unless VotingCredit.active.eligible(voting_session).exists?
110
+
111
+ issues
112
+ end
113
+
114
+ # Mirrors VotePackagesController's collection (status: active, scoped to this season) plus
115
+ # Spree's real purchasability gate (Variant#purchasable?) — nothing in checkout ties a
116
+ # package's purchasability to the voting session's own status, so that's not checked here.
117
+ def vote_package_issues(voting_session)
118
+ return [] if resolve_paid_vote_package(voting_session)
119
+
120
+ [I18n.t('load_test.generate_voting_fixture_data.no_vote_package')]
121
+ end
122
+
123
+ def resolve_paid_vote_package(voting_session)
124
+ vendor = voting_session.vendor
125
+ return nil if vendor.nil?
126
+
127
+ votable_scopes(voting_session).each do |votable_type, votable_id|
128
+ next if votable_id.nil?
129
+
130
+ package = vendor.vote_packages
131
+ .where(status: :active)
132
+ .by_votable(votable_type, votable_id)
133
+ .joins(master: :prices)
134
+ .where.not(spree_prices: { amount: 0 })
135
+ .find { |p| p.vote_variant&.purchasable? }
136
+
137
+ return package if package
138
+ end
139
+
140
+ nil
141
+ end
142
+
143
+ # Same three-level fallback VotingCredit::eligible and VotePackagesController use to scope
144
+ # a session/episode/show to its vote packages and credits.
145
+ def votable_scopes(voting_session)
146
+ [
147
+ ['SpreeCmCommissioner::VotingSession', voting_session.id],
148
+ ['SpreeCmCommissioner::ShowEpisode', voting_session.episode_id],
149
+ ['SpreeCmCommissioner::Show', voting_session.show&.id]
150
+ ]
151
+ end
152
+
153
+ def resolve_payment_method(vendor, flow)
154
+ type = flow == 'aba_topup_vote_flow' ? Vpago::PaymentMethodDecorator::TYPE_PAYWAY_V2 : Vpago::PaymentMethodDecorator::TYPE_CASH_ON
155
+
156
+ Spree::PaymentMethod.active.where(vendor_id: vendor&.id, type: type).first
157
+ end
158
+
159
+ def payment_method_label(flow)
160
+ key = flow == 'aba_topup_vote_flow' ? 'aba' : 'cash_on'
161
+ I18n.t("load_test.generate_voting_fixture_data.payment_method_labels.#{key}")
162
+ end
163
+
164
+ def payway_payload(payment_method)
165
+ {
166
+ host: payment_method.preferred_host,
167
+ checkout_path: PAYWAY_CHECKOUT_PATH,
168
+ merchant_id: payment_method.preferred_merchant_id,
169
+ api_key: payment_method.preferred_api_key,
170
+ payment_option: payment_method.preferred_payment_option,
171
+ transaction_fee_fix: payment_method.preferred_transaction_fee_fix.to_f,
172
+ transaction_fee_percentage: payment_method.preferred_transaction_fee_percentage.to_f,
173
+ enable_pre_auth: payment_method.enable_pre_auth?
174
+ }
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,46 @@
1
+ module SpreeCmCommissioner
2
+ module LoadTest
3
+ # Stateless, signed login token for load-test accounts — modeled on FB/Google-style login.
4
+ #
5
+ # A self-contained JWT whose claims are all the auth layer needs:
6
+ # sub – the load-test user this token logs in as (what UserPasswordAuthenticator trusts)
7
+ # email – that user's email, so it travels inside the token (k6 needs nothing alongside it)
8
+ # aud – the admin who minted it (audit trail, carried through to the copied JSON)
9
+ # exp – hard expiry; JWT.decode rejects an expired token on its own, no DB lookup needed
10
+ #
11
+ # Nothing is stored on the account and nothing is minted at account-creation time. Tokens are
12
+ # generated on demand from the admin UI (System → Load Testing → Generate Login Tokens) and
13
+ # pasted straight into k6, which sends the JWT as the `user_id_token` param on grant_type=password
14
+ # (UserAuthenticator routes a user_id_token that decodes with our secret to the load-test flow).
15
+ module LoginToken
16
+ TTL = 1.hour
17
+ ALGORITHM = 'HS256'.freeze
18
+
19
+ # Purpose-scoped signing key derived from the app's secret_key_base: deterministic per
20
+ # environment (a token minted now still verifies later on the same box), isolated from every
21
+ # other use of secret_key_base, and zero-config — no credential to set up per environment.
22
+ def self.secret
23
+ Rails.application.key_generator.generate_key('load_test/login_token')
24
+ end
25
+
26
+ # audience is required, not defaulted — callers must state who's minting the token (the
27
+ # admin's id) rather than letting it silently fall back to something like 'system'.
28
+ def self.generate(user:, audience:)
29
+ now = Time.current
30
+ expires_at = now + TTL
31
+ payload = { sub: user.id, email: user.email, aud: audience.to_s, iat: now.to_i, exp: expires_at.to_i }
32
+
33
+ { token: JWT.encode(payload, secret, ALGORITHM), expires_at: expires_at }
34
+ end
35
+
36
+ # Returns the decoded payload for a validly-signed, unexpired token; nil otherwise.
37
+ # verify_expiration defaults on, so an expired token raises JWT::ExpiredSignature (a
38
+ # JWT::DecodeError subclass) and is rejected here. aud is informational — not verified.
39
+ def self.decode(token)
40
+ JWT.decode(token, secret, true, algorithm: ALGORITHM).first
41
+ rescue JWT::DecodeError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,56 @@
1
+ module SpreeCmCommissioner
2
+ module Order
3
+ module Guests
4
+ # Whitelists submitted guest params down to whatever KYC fields and dynamic
5
+ # fields are actually configured for the guest's line item, then delegates
6
+ # persistence to UpdateGuestService.
7
+ #
8
+ # Expects params shaped as:
9
+ # { first_name: '...', ..., dynamic_fields: { '<dynamic_field_id>' => value_or_option_id_or_array_of_option_ids } }
10
+ class Update
11
+ prepend ::Spree::ServiceModule::Base
12
+
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)
18
+ end
19
+
20
+ private
21
+
22
+ def permitted_params(guest, params)
23
+ attrs = params.to_h.deep_symbolize_keys
24
+ editable_attrs = guest.kyc_fields.flat_map { |field| SpreeCmCommissioner::KycBitwise::KYC_FIELD_ATTRIBUTES[field] || [] }
25
+
26
+ filtered = attrs.slice(*editable_attrs)
27
+ filtered[:guest_dynamic_fields_attributes] = build_dynamic_field_attributes(guest, attrs[:dynamic_fields])
28
+ filtered
29
+ end
30
+
31
+ # Builds the guest_dynamic_fields_attributes array UpdateGuestService expects,
32
+ # restricted to dynamic fields actually enabled for this guest's product (one
33
+ # `pluck`/load, not a query per submitted field). Option-based fields (checkbox/
34
+ # radio/selection) submit one or more selected option ids and become one
35
+ # GuestDynamicField row per selected option; UpdateGuestService's own auto-cleanup
36
+ # destroys any existing rows for options that were deselected.
37
+ def build_dynamic_field_attributes(guest, dynamic_fields)
38
+ return [] if dynamic_fields.blank?
39
+
40
+ enabled_fields = guest.line_item.product.dynamic_fields.index_by(&:id)
41
+
42
+ dynamic_fields.flat_map do |dynamic_field_id, value|
43
+ dynamic_field = enabled_fields[dynamic_field_id.to_s.to_i]
44
+ next [] unless dynamic_field
45
+
46
+ if dynamic_field.requires_dynamic_field_options?
47
+ Array(value).compact_blank.map { |option_id| { dynamic_field_id: dynamic_field.id, dynamic_field_option_id: option_id } }
48
+ else
49
+ [{ dynamic_field_id: dynamic_field.id, value: value }]
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -81,7 +81,12 @@ module SpreeCmCommissioner
81
81
  end
82
82
 
83
83
  def trip_attrs(form)
84
- { allow_booking: form.allow_booking, allow_seat_selection: form.allow_seat_selection }.compact
84
+ {
85
+ allow_booking: form.allow_booking,
86
+ allow_seat_selection: form.allow_seat_selection,
87
+ name: form.name,
88
+ short_name: form.short_name
89
+ }.compact
85
90
  end
86
91
 
87
92
  def product_attrs(form)
@@ -2,7 +2,9 @@
2
2
  module SpreeCmCommissioner
3
3
  class UserAuthenticator
4
4
  # :username, :password
5
- # :id_token
5
+ # :id_token (Firebase social login)
6
+ # :telegram_id_token -> Telegram OIDC login (Firebase/Google social login)
7
+ # :user_id_token (load-test JWT, self-identifying, minted by LoadTest::LoginToken)
6
8
  # :fb_access_token
7
9
  # :telegram_init_data, :tg_bot
8
10
  # :session_id
@@ -33,9 +35,17 @@ module SpreeCmCommissioner
33
35
  tenant_id = oauth_application&.tenant_id
34
36
 
35
37
  case flow_type(params)
38
+ when 'load_test_auth'
39
+ # k6 sends the load-test JWT as `user_id_token`; internally it's a login_token (see
40
+ # UserPasswordAuthenticator's self-identifying, token-only path).
41
+ options = { login_token: params[:user_id_token], tenant_id: tenant_id }
42
+ SpreeCmCommissioner::UserPasswordAuthenticator.call(options)
36
43
  when 'login_auth'
37
44
  options = { login: params[:username], password: params[:password], tenant_id: tenant_id }
38
45
  SpreeCmCommissioner::UserPasswordAuthenticator.call(options)
46
+ when 'telegram_oauth_auth'
47
+ options = { id_token: params[:telegram_id_token], tenant_id: tenant_id }
48
+ SpreeCmCommissioner::UserTelegramOauthAuthenticator.call(options)
39
49
  when 'social_auth'
40
50
  options = { id_token: params[:id_token], tenant_id: tenant_id }
41
51
  SpreeCmCommissioner::UserIdTokenAuthenticator.call(options)
@@ -52,7 +62,12 @@ module SpreeCmCommissioner
52
62
  end
53
63
 
54
64
  def self.flow_type(params)
65
+ # A separate params key from `id_token` (social login) means we only ever attempt to decode
66
+ # a token that's actually claiming to be a load-test token, instead of decoding every
67
+ # request's id_token to find out.
68
+ return 'load_test_auth' if load_test_id_token?(params[:user_id_token])
55
69
  return 'login_auth' if params.key?(:username) && params.key?(:password)
70
+ return 'telegram_oauth_auth' if params[:telegram_id_token].present?
56
71
  return 'social_auth' if params.key?(:id_token)
57
72
  return 'facebook_auth' if params.key?(:fb_access_token)
58
73
  return 'telegram_web_app_auth' if params.key?(:telegram_init_data) && params.key?(:tg_bot)
@@ -61,6 +76,12 @@ module SpreeCmCommissioner
61
76
  raise exception(I18n.t('authenticator.invalid_or_missing_params'))
62
77
  end
63
78
 
79
+ # True only for a load-test id_token — it's signed with our own secret, so it decodes here
80
+ # whereas a real Firebase/Google id_token (RS256, Google's keys) returns nil.
81
+ def self.load_test_id_token?(token)
82
+ token.present? && SpreeCmCommissioner::LoadTest::LoginToken.decode(token).present?
83
+ end
84
+
64
85
  def self.exception(message)
65
86
  Doorkeeper::Errors::DoorkeeperError.new(message)
66
87
  end
@@ -29,8 +29,8 @@ module SpreeCmCommissioner
29
29
  voting_credit.save!
30
30
  end
31
31
 
32
- total_amount = total_paid_vote_credits + total_free_vote_credits
33
- record_transaction(voting_credit, total_paid_vote_credits) if total_amount.positive?
32
+ record_transaction(voting_credit, total_paid_vote_credits) if total_paid_vote_credits.positive?
33
+ record_transaction_free(voting_credit, total_free_vote_credits) if total_free_vote_credits.positive?
34
34
  end
35
35
 
36
36
  record_free_claims
@@ -99,6 +99,17 @@ module SpreeCmCommissioner
99
99
  )
100
100
  end
101
101
 
102
+ def record_transaction_free(vote_credit, total_amount)
103
+ vote_credit.voting_credit_transactions.create!(
104
+ tenant_id: @order.tenant_id,
105
+ action: :free_claim,
106
+ amount: total_amount,
107
+ originator: @order,
108
+ memo: "Free claim for order ##{@order.number}",
109
+ user_total_amount: vote_credit.available_votes
110
+ )
111
+ end
112
+
102
113
  def record_free_claims
103
114
  return if @order.user_id.blank?
104
115
 
@@ -85,7 +85,7 @@
85
85
  <% if @featured_trips.any? %>
86
86
  <% @featured_trips.each do |trip| %>
87
87
  <tr id="<%= spree_dom_id trip %>">
88
- <td><%= link_to trip.product&.name || trip.id, spree.edit_admin_product_path(trip.product) if trip.product %></td>
88
+ <td><%= link_to trip.display_name || trip.id, spree.edit_admin_product_path(trip.product) if trip.product %></td>
89
89
  <td><%= trip.vendor&.name %></td>
90
90
  <td><%= [trip.origin_place&.name, trip.destination_place&.name].compact.join(' to ') %></td>
91
91
  <td><%= l(trip.departure_time, format: :short) if trip.departure_time %></td>
@@ -106,6 +106,43 @@
106
106
  <%= f.label :phone_number, raw(Spree.t(:phone_number) + required_span_tag) %>
107
107
  <%= f.text_field :phone_number, class: 'form-control' %>
108
108
  <% end %>
109
+ <% when :guest_contact %>
110
+ <%= f.field_container :contact do %>
111
+ <%= f.label :contact, raw(Spree.t(:contact) + required_span_tag) %>
112
+ <%= f.text_field :contact, class: 'form-control' %>
113
+ <% end %>
114
+ <% when :guest_organization %>
115
+ <%= f.field_container :other_organization do %>
116
+ <%= f.label :other_organization, raw(Spree.t(:organization) + required_span_tag) %>
117
+ <%= f.text_field :other_organization, class: 'form-control' %>
118
+ <% end %>
119
+ <% when :guest_address %>
120
+ <%= f.field_container :address do %>
121
+ <%= f.label :address, raw(Spree.t(:address) + required_span_tag) %>
122
+ <%= f.text_field :address, class: 'form-control' %>
123
+ <% end %>
124
+ <% when :guest_social_contact %>
125
+ <div class="row">
126
+ <div class="col-12 col-lg-6">
127
+ <%= f.field_container :social_contact_platform do %>
128
+ <%= f.label :social_contact_platform, raw(Spree.t(:social_contact_platform) + required_span_tag) %>
129
+ <%= f.select :social_contact_platform,
130
+ SpreeCmCommissioner::Guest.social_contact_platforms.keys.map { |platform| [platform.to_s.capitalize, platform] },
131
+ { include_blank: true }, class: 'fullwidth select2' %>
132
+ <% end %>
133
+ </div>
134
+ <div class="col-12 col-lg-6">
135
+ <%= f.field_container :social_contact do %>
136
+ <%= f.label :social_contact, raw(Spree.t(:social_contact) + required_span_tag) %>
137
+ <%= f.text_field :social_contact, class: 'form-control' %>
138
+ <% end %>
139
+ </div>
140
+ </div>
141
+ <% when :guest_expectation %>
142
+ <%= f.field_container :expectation do %>
143
+ <%= f.label :expectation, raw(Spree.t(:expectation) + required_span_tag) %>
144
+ <%= f.text_area :expectation, class: 'form-control', rows: 3 %>
145
+ <% end %>
109
146
  <% end %>
110
147
  <% end %>
111
148
  </div>
@@ -23,4 +23,11 @@
23
23
  admin_system_maintenance_tasks_path,
24
24
  class: "nav-link #{'active' if current == :maintenance_tasks}" %>
25
25
  <% end %>
26
+
27
+ <%= content_tag :li, class: 'nav-item' do %>
28
+ <%= link_to_with_icon 'speedometer.svg',
29
+ Spree.t('load_test.nav_label'),
30
+ admin_system_load_testing_index_path,
31
+ class: "nav-link #{'active' if current == :load_testing}" %>
32
+ <% end %>
26
33
  <% end %>