spree_cm_commissioner 2.10.0 → 2.10.2.pre.pre1

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/admin/vendor_authorized_users_controller.rb +15 -0
  4. data/app/controllers/spree/admin/vendors_controller_decorator.rb +7 -0
  5. data/app/errors/spree_cm_commissioner/reserved_blocks/locked_by_operator_error.rb +25 -0
  6. data/app/finders/spree_cm_commissioner/accommodations/find_variant.rb +2 -2
  7. data/app/jobs/spree_cm_commissioner/async_operation_job.rb +27 -0
  8. data/app/jobs/spree_cm_commissioner/ticket_transfers/expire_job.rb +29 -0
  9. data/app/jobs/spree_cm_commissioner/voting_session_auto_close_job.rb +22 -0
  10. data/app/jobs/spree_cm_commissioner/voting_session_firestore_publish_job.rb +18 -0
  11. data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +27 -0
  12. data/app/models/concerns/spree_cm_commissioner/order_channel_bitwise.rb +70 -0
  13. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +21 -6
  14. data/app/models/concerns/spree_cm_commissioner/ticket_transferable.rb +13 -2
  15. data/app/models/concerns/spree_cm_commissioner/vendor_preference.rb +19 -0
  16. data/app/models/concerns/spree_cm_commissioner/voting_session_config_concern.rb +87 -0
  17. data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +137 -0
  18. data/app/models/spree_cm_commissioner/async_operation.rb +73 -0
  19. data/app/models/spree_cm_commissioner/async_operations/seat_lock.rb +76 -0
  20. data/app/models/spree_cm_commissioner/block.rb +32 -2
  21. data/app/models/spree_cm_commissioner/guest.rb +5 -0
  22. data/app/models/spree_cm_commissioner/inventory_item.rb +17 -1
  23. data/app/models/spree_cm_commissioner/maintenance_tasks/voting_session.rb +6 -5
  24. data/app/models/spree_cm_commissioner/order_decorator.rb +2 -14
  25. data/app/models/spree_cm_commissioner/payment_decorator.rb +4 -14
  26. data/app/models/spree_cm_commissioner/promotion_decorator.rb +19 -0
  27. data/app/models/spree_cm_commissioner/reserved_block.rb +29 -1
  28. data/app/models/spree_cm_commissioner/show_episode.rb +3 -1
  29. data/app/models/spree_cm_commissioner/ticket_transfer.rb +5 -1
  30. data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -0
  31. data/app/models/spree_cm_commissioner/vendor_decorator.rb +10 -0
  32. data/app/models/spree_cm_commissioner/voting_contestant.rb +34 -1
  33. data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
  34. data/app/models/spree_cm_commissioner/voting_session.rb +44 -83
  35. data/app/overrides/spree/admin/vendors/_form/sellable_product_types.html.erb.deface +9 -0
  36. data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +4 -0
  37. data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -1
  38. data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +6 -1
  39. data/app/serializers/spree_cm_commissioner/v2/storefront/guest_serializer.rb +1 -0
  40. data/app/services/spree_cm_commissioner/checkout/complete_decorator.rb +16 -5
  41. data/app/services/spree_cm_commissioner/fraud_check.rb +2 -2
  42. data/app/services/spree_cm_commissioner/guests/update_seat.rb +15 -0
  43. data/app/services/spree_cm_commissioner/intercity_taxi_order/create.rb +4 -2
  44. data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +83 -0
  45. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +5 -1
  46. data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +19 -8
  47. data/app/services/spree_cm_commissioner/reserved_blocks/cancel.rb +5 -1
  48. data/app/services/spree_cm_commissioner/reserved_blocks/hold.rb +14 -3
  49. data/app/services/spree_cm_commissioner/reserved_blocks/reserve.rb +4 -0
  50. data/app/services/spree_cm_commissioner/seats/apply_locks.rb +105 -0
  51. data/app/services/spree_cm_commissioner/seats/block_date_iteration.rb +73 -0
  52. data/app/services/spree_cm_commissioner/seats/lock_blocks.rb +136 -0
  53. data/app/services/spree_cm_commissioner/seats/lock_state.rb +159 -0
  54. data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +71 -0
  55. data/app/services/spree_cm_commissioner/ticket_transfers/accept.rb +6 -5
  56. data/app/services/spree_cm_commissioner/ticket_transfers/complete.rb +4 -4
  57. data/app/services/spree_cm_commissioner/ticket_transfers/expire.rb +7 -1
  58. data/app/services/spree_cm_commissioner/transit_order/create.rb +10 -4
  59. data/app/services/spree_cm_commissioner/vote_counters/base.rb +3 -1
  60. data/app/services/spree_cm_commissioner/vote_counters/increment.rb +1 -1
  61. data/app/services/spree_cm_commissioner/vote_counters/snapshot_to_db.rb +8 -2
  62. data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +103 -0
  63. data/app/views/spree/admin/vendor_authorized_users/index.html.erb +15 -0
  64. data/config/initializers/spree_permitted_attributes.rb +3 -0
  65. data/config/locales/en.yml +9 -0
  66. data/config/locales/km.yml +2 -0
  67. data/db/migrate/20260806000000_add_metadata_to_cm_reserved_blocks.rb +8 -0
  68. data/db/migrate/20260806000001_add_metadata_to_cm_inventory_items.rb +6 -0
  69. data/db/migrate/20260811000000_add_vote_window_to_cm_voting_sessions.rb +14 -0
  70. data/db/migrate/20260811000001_add_visible_to_cm_voting_contestants.rb +9 -0
  71. data/db/migrate/20260811000002_add_visible_to_cm_voting_sessions.rb +8 -0
  72. data/db/migrate/20260811000004_reorder_lifecycle_status_on_cm_voting_sessions.rb +40 -0
  73. data/db/migrate/20260812000000_create_cm_async_operations.rb +23 -0
  74. data/db/migrate/20260812000000_replace_timing_mode_with_vote_window_on_cm_voting_sessions.rb +21 -0
  75. data/db/migrate/20260812125617_add_metadata_to_spree_vendors.rb +6 -0
  76. data/lib/spree_cm_commissioner/test_helper/factories/async_operation_factory.rb +29 -0
  77. data/lib/spree_cm_commissioner/test_helper/factories/reserved_block_factory.rb +8 -0
  78. data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +14 -0
  79. data/lib/spree_cm_commissioner/version.rb +1 -1
  80. metadata +33 -5
  81. data/app/jobs/spree_cm_commissioner/expire_ticket_transfer_job.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d59a44e1e25ec19ab8c634622f91006ea28bec265ea877dad8565cffa54bad3
4
- data.tar.gz: 43415e8984f459f0387e110707963f4685c92251f589a2301342f82783c4e714
3
+ metadata.gz: e5552124388d0792051115573a9cc5e51044c8edcf7f8e89c6f03b43d2ebd0ab
4
+ data.tar.gz: '0694645f790079a03ebad09626ca155e7495c37ea8b45f83064a3c31b2aa6f75'
5
5
  SHA512:
6
- metadata.gz: 1fbfc67d4bc18eb071d854b643ef3bfbaf1312b75f5c241020d58b09855ee234a58064ce08a104ee856b48dec25ce8547ad9c442cbd2648c83ad350c04fec3e1
7
- data.tar.gz: 53317e748ac053ed6b60ec7e9accdd915266aa6b3e547692865191868d6051693dcad05dee73bed5e196b027fc27436e72fba2c62e05b3b33f806a7558618cf9
6
+ metadata.gz: 06b79003d8dee84088c17d6cd6f90b7d2b7d5eeca20b4045ca1775b467b3455b66898e72e47d33e9655a6b8b7c1b36dcef355b8b4ff5258daa066d87e8cdfb02
7
+ data.tar.gz: 386411dbfdd18ce5d25ade6d55e045b1bcaea788d3b8cb49437723789ab8e1e5e7e1b77e4cbece9ee962427f8b0c5c603c1cd1cdc091400ca8cf6e860dfd39a4
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.10.0)
37
+ spree_cm_commissioner (2.10.2.pre.pre1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -14,6 +14,8 @@ module Spree
14
14
  end
15
15
 
16
16
  def update_telegram_chat
17
+ @vendor.update(preferred_telegram_alert_channels: submitted_alert_channels_mask)
18
+
17
19
  context = ::SpreeCmCommissioner::TelegramChatsFinder.call(
18
20
  telegram_chat_name: params['vendor']['preferred_telegram_chat_name'],
19
21
  telegram_chat_type: params['vendor']['preferred_telegram_chat_type']
@@ -34,6 +36,19 @@ module Spree
34
36
  redirect_to collection_url(@vendor)
35
37
  end
36
38
 
39
+ # OR the bits, never sum them. Sum is not idempotent: if the same bit arrives twice
40
+ # (duplicated checkbox, double-submit, hand-crafted post) 1 + 1 = 2, which silently means
41
+ # google_form instead of spree. ["0","1","1","16"].sum => 18 => [google_form, offline_sell];
42
+ # the same input OR'd => 17 => [spree, offline_sell].
43
+ #
44
+ # Masking against the known bits also drops junk from a crafted post (999 => 7).
45
+ def submitted_alert_channels_mask
46
+ known = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS.values.reduce(:|)
47
+
48
+ Array(params.dig(:vendor, :telegram_alert_channels))
49
+ .reduce(0) { |acc, value| acc | value.to_i } & known
50
+ end
51
+
37
52
  # @overrided
38
53
  def model_class
39
54
  Spree::User
@@ -5,10 +5,17 @@ module Spree
5
5
  base.before_action :build_logo, only: %i[create update]
6
6
  base.before_action :build_payment_qrcode, only: %i[create update]
7
7
  base.before_action :contruct_nearby_place, only: %i[create update]
8
+ base.before_action :compact_sellable_product_types, only: %i[create update]
8
9
  end
9
10
 
10
11
  private
11
12
 
13
+ def compact_sellable_product_types
14
+ return unless permitted_resource_params.key?(:sellable_product_types)
15
+
16
+ permitted_resource_params[:sellable_product_types] = permitted_resource_params[:sellable_product_types].compact_blank
17
+ end
18
+
12
19
  def contruct_nearby_place
13
20
  return unless permitted_resource_params.key?(:nearby_places_attributes)
14
21
 
@@ -0,0 +1,25 @@
1
+ module SpreeCmCommissioner
2
+ module ReservedBlocks
3
+ # Raised when a customer tries to hold or reserve a seat an operator has taken off sale.
4
+ #
5
+ # The customer-facing message deliberately says only "not available" — it must not leak the
6
+ # operator's reason (e.g. "held for staff"), which lives in private_metadata.
7
+ class LockedByOperatorError < StandardError
8
+ attr_reader :block_label
9
+
10
+ def initialize(block_label = nil)
11
+ @block_label = block_label
12
+ super()
13
+ end
14
+
15
+ # override
16
+ def message
17
+ if block_label.present?
18
+ I18n.t('line_item.validation.blocks_are_locked_by_operator_with_label', label: block_label)
19
+ else
20
+ I18n.t('line_item.validation.blocks_are_locked_by_operator')
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -15,8 +15,8 @@ module SpreeCmCommissioner
15
15
  .joins(:inventory_items)
16
16
  .where(vendor_id: vendor_id)
17
17
  .where(inventory_items: { inventory_date: stay_dates })
18
- .where('CAST(public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
19
- CAST(public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
18
+ .where('CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-adults\' AS INTEGER) +
19
+ CAST(spree_variants.public_metadata->\'cm_options\'->>\'number-of-kids\' AS INTEGER) >= ?', number_of_guests
20
20
  )
21
21
  .where('inventory_items.quantity_available > 0')
22
22
  .distinct
@@ -0,0 +1,27 @@
1
+ module SpreeCmCommissioner
2
+ # Runs any AsyncOperation subclass. Same shape as ExportJob: load the record, call the one method the
3
+ # subclass implements.
4
+ #
5
+ # ApplicationJob, not ApplicationUniqueJob: `unique :until_executed` keys on the job arguments, and
6
+ # those carry a per-record id, so uniqueness could never collide -- it would buy a Redis lock for
7
+ # nothing. The in_progress? guard below is what actually makes a duplicate enqueue a no-op.
8
+ class AsyncOperationJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ def perform(options = {})
12
+ operation = SpreeCmCommissioner::AsyncOperation.find_by(id: options[:async_operation_id])
13
+ return if operation.blank?
14
+
15
+ # Guard on `done` only, not on "not in progress". An exception below marks the operation failed and
16
+ # re-raises so Sidekiq retries -- guarding on in_progress? would make every one of those retries
17
+ # return here immediately, which is the opposite of retrying. Completed work is the thing that must
18
+ # never run twice; a failed operation is exactly what a retry is for.
19
+ return if operation.done?
20
+
21
+ operation.process!
22
+ rescue StandardError => e
23
+ operation&.fail!(e.message)
24
+ raise # ApplicationJob logs it; Sidekiq retries; Sentry captures.
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module SpreeCmCommissioner
2
+ module TicketTransfers
3
+ class ExpireJob < ApplicationJob
4
+ # Same window as OrderHolds::LockForPayment — gives an in-flight gateway
5
+ # redirect/webhook (e.g. PayWay, ACLEDA) room to resolve before we retry.
6
+ RETRY_DELAY = ENV.fetch('PAYMENT_LOCK_MIN_DURATION_IN_MINUTES', '5').to_i.minutes
7
+ MAX_RETRIES = 1
8
+ UNRESOLVED_PAYMENT_STATES = %w[checkout pending processing].freeze
9
+
10
+ def perform(transfer_id, attempt = 0)
11
+ ticket_transfer = SpreeCmCommissioner::TicketTransfer.find_by(id: transfer_id)
12
+ return unless ticket_transfer
13
+
14
+ if payment_in_progress?(ticket_transfer) && attempt < MAX_RETRIES
15
+ self.class.set(wait: RETRY_DELAY).perform_later(transfer_id, attempt + 1)
16
+ return
17
+ end
18
+
19
+ SpreeCmCommissioner::TicketTransfers::Expire.call(ticket_transfer: ticket_transfer)
20
+ end
21
+
22
+ private
23
+
24
+ def payment_in_progress?(ticket_transfer)
25
+ ticket_transfer.order&.payments&.where(state: UNRESOLVED_PAYMENT_STATES)&.exists? || false
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ module SpreeCmCommissioner
2
+ # Flips the Firestore `state` to closed once a manually-started vote window elapses.
3
+ #
4
+ # Enqueued by VotingSessionTimingConcern#start_vote! with wait_until: vote_close_at. Vote
5
+ # rejection does not depend on this job — can_vote? already gates on the persisted
6
+ # vote_close_at — so a missed run costs a stale label in the app, never an accepted late vote.
7
+ class VotingSessionAutoCloseJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform(voting_session_id)
11
+ voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
12
+ return if voting_session.nil?
13
+
14
+ # vote_close_at may have moved since this was scheduled (a restart, or the organizer
15
+ # pressing Stop Vote and starting a new window). Only publish once the window has genuinely
16
+ # elapsed; otherwise the run that matches the new vote_close_at will handle it.
17
+ return if voting_session.vote_close_at.present? && Time.current < voting_session.vote_close_at
18
+
19
+ SpreeCmCommissioner::VotingSessions::PublishToFirestore.call(voting_session: voting_session)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ module SpreeCmCommissioner
2
+ # Pushes a voting session's live state to Firestore off the request cycle, so an organizer
3
+ # pressing "Start Vote" never waits on a Firestore round-trip.
4
+ #
5
+ # Takes an id rather than the record so a retry publishes the state as it is *now*, not as it
6
+ # was when the job was enqueued — a rapid start/pause/stop sequence must not have a stale
7
+ # retry overwrite the current state.
8
+ class VotingSessionFirestorePublishJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ def perform(voting_session_id)
12
+ voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
13
+ return if voting_session.nil?
14
+
15
+ SpreeCmCommissioner::VotingSessions::PublishToFirestore.call(voting_session: voting_session)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ require 'google/cloud/firestore'
2
+
3
+ module SpreeCmCommissioner
4
+ # Shared Cloud Firestore client construction.
5
+ #
6
+ # The waiting-room services each inline this same pair (see WaitingGuestsCaller#firestore);
7
+ # this concern is the extracted version for new writers, plus a #firestore_available? guard so
8
+ # environments without the credential (dev, test, CI) no-op instead of raising on connect.
9
+ module FirestoreConnection
10
+ extend ActiveSupport::Concern
11
+
12
+ def firestore_available?
13
+ service_account.present?
14
+ end
15
+
16
+ def firestore
17
+ @firestore ||= Google::Cloud::Firestore.new(
18
+ project_id: service_account[:project_id],
19
+ credentials: service_account
20
+ )
21
+ end
22
+
23
+ def service_account
24
+ @service_account ||= Rails.application.credentials.cloud_firestore_service_account
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,70 @@
1
+ # Owns everything about an order's `channel`: the allowed values, how a stored value
2
+ # resolves back to one of them, and the bitmask vendors use to choose which channels
3
+ # may alert their Telegram group.
4
+ #
5
+ # `channel` is validated by PREFIX, not equality, because the CSV importer stores
6
+ # "google_form-<import_order_id>-<slug>". Always compare via #channel_key, never `==`.
7
+ module SpreeCmCommissioner
8
+ module OrderChannelBitwise
9
+ extend ActiveSupport::Concern
10
+
11
+ SPREE_CHANNEL = 'spree'.freeze
12
+ GOOGLE_FORM_CHANNEL = 'google_form'.freeze
13
+ TELEGRAM_CHANNEL = 'telegram'.freeze
14
+ TICKET_TRANSFER_CHANNEL = 'ticket_transfer'.freeze
15
+ OFFLINE_SELL_CHANNEL = 'offline_sell'.freeze
16
+
17
+ ALLOWED_CHANNEL_PREFIXES = [
18
+ SPREE_CHANNEL, GOOGLE_FORM_CHANNEL, TELEGRAM_CHANNEL, TICKET_TRANSFER_CHANNEL, OFFLINE_SELL_CHANNEL
19
+ ].freeze
20
+
21
+ # Matched longest-first so a future 'spree_x' can never be swallowed by 'spree'.
22
+ SORTED_CHANNEL_PREFIXES = ALLOWED_CHANNEL_PREFIXES.sort_by { |prefix| -prefix.length }.freeze
23
+
24
+ # Bit values are PERSISTED in vendor preferences and are a permanent contract.
25
+ # Never renumber, reorder, or reuse a bit. To retire a channel, leave its bit
26
+ # burned and take the next free power of two.
27
+ #
28
+ # Deliberately literal, never derived from ALLOWED_CHANNEL_PREFIXES.index —
29
+ # reordering that array would silently remap every vendor's saved setting.
30
+ CHANNEL_ALERT_BITS = {
31
+ SPREE_CHANNEL => 1, # 1 << 0
32
+ GOOGLE_FORM_CHANNEL => 2, # 1 << 1
33
+ TELEGRAM_CHANNEL => 4, # 1 << 2
34
+ TICKET_TRANSFER_CHANNEL => 8, # 1 << 3
35
+ OFFLINE_SELL_CHANNEL => 16 # 1 << 4
36
+ }.freeze
37
+
38
+ # Every bit currently in use, for masking untrusted input down to known channels.
39
+ KNOWN_CHANNEL_ALERT_BITS = CHANNEL_ALERT_BITS.values.reduce(:|).freeze
40
+
41
+ # Only online orders alert by default. google_form & ticket_transfer set the state column
42
+ # directly instead of firing the state machine, so they have never sent an alert; this
43
+ # default therefore encodes today's real behaviour rather than changing it.
44
+ DEFAULT_TELEGRAM_ALERT_CHANNELS = CHANNEL_ALERT_BITS[SPREE_CHANNEL]
45
+
46
+ included do
47
+ validate :validate_channel_prefix, if: :channel_changed?
48
+ end
49
+
50
+ # The stored channel may carry a suffix — the importer writes
51
+ # "google_form-<import_order_id>-<slug>" — so the prefix, not the whole
52
+ # string, is the channel's identity. Returns nil for an unrecognized channel.
53
+ def channel_key
54
+ SORTED_CHANNEL_PREFIXES.find { |prefix| channel&.start_with?(prefix) }
55
+ end
56
+
57
+ # Order was created by an operator through the back-office Sell Ticket console.
58
+ def offline_sell?
59
+ channel_key == OFFLINE_SELL_CHANNEL
60
+ end
61
+
62
+ private
63
+
64
+ def validate_channel_prefix
65
+ return if channel_key.present?
66
+
67
+ errors.add(:channel, "must start with one of the following: #{ALLOWED_CHANNEL_PREFIXES.join(', ')}")
68
+ end
69
+ end
70
+ end
@@ -1,7 +1,7 @@
1
1
  # Make sure to put this concern below other concern or methods that generating additional order info like guests, seat number, etc.
2
2
  # This will ensure that when each notification is sent with neccessary data.
3
3
  module SpreeCmCommissioner
4
- module OrderStateMachine
4
+ module OrderStateMachine # rubocop:disable Metrics/ModuleLength
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do # rubocop:disable Metrics/BlockLength
@@ -271,15 +271,30 @@ module SpreeCmCommissioner
271
271
  return unless telegram_alert_enabled?
272
272
 
273
273
  vendor_list.each do |vendor|
274
- title = '🎫 --- [NEW ORDER FROM BOOKME+] ---'
274
+ # `next`, not `return`: the mask is per-vendor, so one opted-out vendor on a
275
+ # multi-vendor order must not suppress the others.
276
+ next unless vendor.alerts_telegram_for_channel?(channel_key)
277
+
275
278
  chat_id = vendor.preferred_telegram_chat_id
276
- if chat_id.present?
277
- factory = OrderTelegramMessageFactory.new(title: title, order: self, vendor: vendor)
278
- TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
279
- end
279
+ next if chat_id.blank?
280
+
281
+ factory = OrderTelegramMessageFactory.new(title: vendor_alert_title(vendor), order: self, vendor: vendor)
282
+ TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
280
283
  end
281
284
  end
282
285
 
286
+ # White-label vendors announce their own brand; platform vendors stay BookMe+.
287
+ #
288
+ # vendor.name is operator-supplied data interpolated into an HTML parse_mode message
289
+ # (TelegramMessageFactory#header wraps the title in <b>), so it MUST be escaped —
290
+ # Telegram rejects unescaped &, < and > with "400 can't parse entities", and
291
+ # TelegramNotificationSender swallows that error, so the alert would vanish silently.
292
+ def vendor_alert_title(vendor)
293
+ return '🎫 --- [NEW ORDER FROM BOOKME+] ---' unless vendor.tenant_present?
294
+
295
+ "🎫 --- [NEW ORDER FROM #{CGI.escapeHTML(vendor.name.to_s)}] ---"
296
+ end
297
+
283
298
  def notify_order_complete_app_notification_to_user
284
299
  return if products.all?(&:vote_package?) # disable app push notification for vote package orders
285
300
 
@@ -5,11 +5,22 @@ module SpreeCmCommissioner
5
5
  included do
6
6
  has_one :ticket_transfer, class_name: 'SpreeCmCommissioner::TicketTransfer'
7
7
 
8
- scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL) }
8
+ scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::OrderChannelBitwise::TICKET_TRANSFER_CHANNEL) }
9
9
  end
10
10
 
11
11
  def ticket_transfer?
12
- channel == SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL
12
+ channel == SpreeCmCommissioner::OrderChannelBitwise::TICKET_TRANSFER_CHANNEL
13
+ end
14
+
15
+ # Completes an accepted ticket transfer for this order. Called from
16
+ # Spree::Payment#after_completed, so the buyer's payment is already captured.
17
+ def complete_ticket_transfer_if_ready
18
+ return unless ticket_transfer?
19
+
20
+ transfer = ticket_transfer
21
+ return if transfer.blank? || !transfer.accepted?
22
+
23
+ SpreeCmCommissioner::TicketTransfers::Complete.call(ticket_transfer: transfer)
13
24
  end
14
25
  end
15
26
  end
@@ -8,6 +8,25 @@ module SpreeCmCommissioner
8
8
  preference :telegram_chat_id, :string
9
9
  preference :telegram_chat_name, :string
10
10
  preference :telegram_chat_type, :string
11
+ preference :telegram_alert_channels, :integer, default: 1
12
+ end
13
+
14
+ # Should this vendor's Telegram group be alerted for an order on this channel?
15
+ # An unmapped channel alerts — a stray message is a better failure than silence.
16
+ def alerts_telegram_for_channel?(channel_key)
17
+ bit = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS[channel_key]
18
+ return true if bit.nil?
19
+
20
+ telegram_alert_channels_mask.anybits?(bit)
21
+ end
22
+
23
+ # Spree's getter is `preferences.fetch(name) { default }`, which falls back only when the
24
+ # key is ABSENT. A key present with a nil value returns nil, and nil.to_i == 0 would mute
25
+ # EVERY channel silently. Fall back to the default instead; a deliberate 0 survives
26
+ # because 0 is truthy in Ruby.
27
+ def telegram_alert_channels_mask
28
+ (preferred_telegram_alert_channels ||
29
+ SpreeCmCommissioner::OrderChannelBitwise::DEFAULT_TELEGRAM_ALERT_CHANNELS).to_i
11
30
  end
12
31
  end
13
32
  end
@@ -0,0 +1,87 @@
1
+ module SpreeCmCommissioner
2
+ # The fraud_config/voting_config JSON stores and their derived, show-inheriting accessors.
3
+ # Extracted purely to keep VotingSession under RuboCop's Metrics/ClassLength — no behavior
4
+ # here depends on anything else in the concern split (unlike VotingSessionTimingConcern, whose
5
+ # enum/predicate ordering matters).
6
+ module VotingSessionConfigConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ store_accessor :fraud_config,
11
+ :max_votes_per_minute_per_user,
12
+ :max_votes_per_minute_per_ip,
13
+ :max_accounts_per_device,
14
+ :block_vpn,
15
+ :max_votes_per_contestant_per_user
16
+
17
+ store_accessor :voting_config,
18
+ :free_vote_limit,
19
+ :free_vote_limit_type,
20
+ :allow_user_to_get_free_vote,
21
+ :paid_vote_enabled,
22
+ :max_paid_votes_per_session,
23
+ :sms_vote_enabled,
24
+ :sms_vote_cost_usd,
25
+ :operator_vote_enabled,
26
+ :votes_weight,
27
+ :voters_weight,
28
+ :suggestion_top_n,
29
+ :pre_vote_weight
30
+
31
+ validate :weights_present_for_weighted_sum
32
+ end
33
+
34
+ DEFAULT_SUGGESTION_TOP_N = 1
35
+
36
+ def effective_suggestion_top_n
37
+ suggestion_top_n&.to_i&.positive? ? suggestion_top_n.to_i : DEFAULT_SUGGESTION_TOP_N
38
+ end
39
+
40
+ def effective_voting_config
41
+ return {} unless show
42
+
43
+ config = parse_json_config(voting_config)
44
+ show_config = show.effective_voting_config
45
+ config.reverse_merge(show_config || {})
46
+ end
47
+
48
+ def effective_fraud_config
49
+ return {} unless show
50
+
51
+ config = parse_json_config(fraud_config)
52
+ show_config = show.effective_fraud_config
53
+ config.reverse_merge(show_config || {})
54
+ end
55
+
56
+ def max_votes_per_user
57
+ effective_voting_config['max_votes_per_user'] || 10
58
+ end
59
+
60
+ def rate_limit_window
61
+ effective_voting_config['rate_limit_window'] || 60
62
+ end
63
+
64
+ def rate_limit_max_votes
65
+ effective_voting_config['rate_limit_max_votes'] || 5
66
+ end
67
+
68
+ private
69
+
70
+ def weights_present_for_weighted_sum
71
+ return unless weighted_sum?
72
+
73
+ config = effective_voting_config
74
+ errors.add(:votes_weight, :blank) if config['votes_weight'].blank?
75
+ errors.add(:voters_weight, :blank) if config['voters_weight'].blank?
76
+ end
77
+
78
+ def parse_json_config(value)
79
+ return {} if value.nil? || value == ''
80
+ return value if value.is_a?(Hash)
81
+
82
+ JSON.parse(value)
83
+ rescue JSON::ParserError
84
+ {}
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,137 @@
1
+ module SpreeCmCommissioner
2
+ # How a voting session's vote window (vote_open_at/vote_close_at) gets its values, kept
3
+ # deliberately separate from opens_at/closes_at — the show's own time slot within its episode
4
+ # (an episode can span many voting sessions, e.g. one per day of a week-long run), which has
5
+ # nothing to do with when voting is actually open.
6
+ #
7
+ # There is no mode flag: the presence of vote_open_at/vote_close_at is the signal.
8
+ #
9
+ # Pre-set at creation — auto-activates: once #activate flips status to enabled, can_vote?
10
+ # just compares Time.current against the already-real timestamps.
11
+ # Left blank — #start_vote! rewrites them to now / now + vote_duration_seconds the
12
+ # moment the organizer presses it, live during the broadcast.
13
+ #
14
+ # Both paths start at status: archived and reach enabled through one of two routes:
15
+ #
16
+ # archived --(check "Visible")--> on_air --#start_vote!--> enabled (progressive reveal, then vote)
17
+ # archived ------------------------------#start_vote!--> enabled (reveal and vote in one click)
18
+ #
19
+ # There is no separate "reveal" action: setting visible: true while archived (from the organizer
20
+ # dashboard's edit form) *is* the reveal — see #promote_from_archived_when_made_visible, which
21
+ # auto-transitions status to on_air in the same write rather than rejecting the attempt. on_air
22
+ # exists specifically so a session can show its contestants one by one — in sync with a live
23
+ # performance — before voting opens; it is visible but never votable (status isn't enabled, so
24
+ # can_vote? is false regardless of vote_open_at).
25
+ module VotingSessionTimingConcern
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ validates :vote_duration_seconds, numericality: { greater_than: 0 }, allow_nil: true
30
+ validate :vote_closes_after_vote_opens
31
+
32
+ # before_update, not before_save: the `visible` column defaults to true at the DB level, so
33
+ # a brand-new record saved with the default status: archived (see
34
+ # ParamsConcern#force_archived_status_on_create, which explicitly sets visible: false too)
35
+ # must never auto-promote itself on its first save just because it hasn't touched `visible`
36
+ # yet — "reveal" is something an organizer does to an *existing* archived session.
37
+ before_update :promote_from_archived_when_made_visible
38
+ after_commit :publish_live_state_to_firestore, on: %i[create update], if: :live_state_changed?
39
+
40
+ scope :visible, -> { where(visible: true) }
41
+ end
42
+
43
+ # Columns the show screen renders. `display` is included because hiding a session should
44
+ # disappear from the app as promptly as opening one appears.
45
+ LIVE_STATE_COLUMNS = %w[status opens_at closes_at vote_open_at vote_close_at display visible].freeze
46
+
47
+ # Opens the vote window *now* rather than at a pre-set time, overriding whatever vote_open_at
48
+ # already held (a live show running early is exactly when this gets pressed). visible: true is
49
+ # set explicitly so this also works when called directly from archived — revealing and opening
50
+ # voting in one click, without going through on_air first.
51
+ def start_vote!
52
+ now = Time.current
53
+ update!(
54
+ vote_open_at: now,
55
+ vote_close_at: now + vote_duration_seconds.to_i.seconds,
56
+ status: :enabled,
57
+ visible: true
58
+ )
59
+
60
+ SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
61
+ end
62
+
63
+ # Ends the vote window immediately and moves to closed. status: closed (not just a rewritten
64
+ # vote_close_at) matters for two reasons: votes are rejected the instant this runs, since
65
+ # can_vote? short-circuits on enabled? before ever comparing timestamps — no dependency on the
66
+ # inclusive Time#between? boundary at vote_close_at; and it's what triggers
67
+ # schedule_counter_reconciliation (only fires on saved_change_to_status?(enabled -> closed)),
68
+ # which would otherwise only run via the separate "Close" action, never on a manual stop.
69
+ #
70
+ # vote_close_at clamped to vote_open_at + 1s: an organizer who starts and immediately stops
71
+ # would otherwise produce vote_close_at == vote_open_at, which the closes-after-opens
72
+ # validation rejects — a validation error on what is a legitimate "cancel that" action.
73
+ def stop_vote!
74
+ opened_at = vote_open_at || Time.current
75
+ update!(status: :closed, vote_close_at: [Time.current, opened_at + 1.second].max)
76
+ end
77
+
78
+ # True when voting hasn't opened yet — either vote_open_at was never set, it's set but its
79
+ # time hasn't arrived, or status is on_air. The on_air check matters on its own: on_air can be
80
+ # revisited after a prior round already closed (an organizer reverting status by hand to
81
+ # re-reveal), in which case vote_open_at/vote_close_at are still populated from that earlier
82
+ # round and both in the past — without this, the timestamp check alone would say the window
83
+ # isn't pending, and Firestore's state_for would wrongly report 'closed' for a session that's
84
+ # actually on_air right now. Covers "never started," "pre-scheduled but not live yet," and
85
+ # "on_air again after a previous round" identically, since none of them is votable nor has a
86
+ # real countdown to show.
87
+ def vote_window_pending?
88
+ on_air? || vote_open_at.blank? || Time.current < vote_open_at
89
+ end
90
+
91
+ # Shifts vote_close_at by `seconds` (organizer-chosen at the moment, not necessarily
92
+ # vote_duration_seconds) without touching vote_open_at — distinct from #start_vote!, which
93
+ # would discard the time already elapsed by resetting the window to start now.
94
+ #
95
+ # Extends from max(vote_close_at, Time.current), not from the raw stored value: the
96
+ # controller also offers this once a window has already lapsed (status still enabled, nothing
97
+ # auto-flipped it to closed yet) specifically to reopen it. Adding straight to a stale
98
+ # vote_close_at sitting hours in the past would land the new value in the past too — "+10
99
+ # minutes" on a session that closed last night must mean 10 minutes from now, not 10 minutes
100
+ # from when it closed.
101
+ #
102
+ # `seconds` may be negative to cut a currently-open window short without a full Stop Vote;
103
+ # going negative enough to land at or before vote_open_at raises via the closes-after-opens
104
+ # validation.
105
+ def extend_vote!(seconds)
106
+ update!(vote_close_at: [vote_close_at, Time.current].max + seconds.to_i.seconds)
107
+
108
+ SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
109
+ end
110
+
111
+ private
112
+
113
+ # archived sessions are prepped privately by default (visible starts false), but the organizer
114
+ # controls visibility directly from the edit form — checking "Visible to Viewers" while
115
+ # archived is the reveal action, not a separate button. Rather than rejecting that write, treat
116
+ # it as intent: promote status to on_air in the same save, so the session actually becomes
117
+ # reachable via the `current` scope (which never includes plain `archived`, regardless of
118
+ # `visible`). Setting visible: false while archived is a no-op — status simply stays archived.
119
+ def promote_from_archived_when_made_visible
120
+ self.status = 'on_air' if archived? && visible?
121
+ end
122
+
123
+ def vote_closes_after_vote_opens
124
+ return unless vote_open_at && vote_close_at
125
+
126
+ errors.add(:vote_close_at, :must_be_after_vote_opens_at) if vote_close_at <= vote_open_at
127
+ end
128
+
129
+ def live_state_changed?
130
+ LIVE_STATE_COLUMNS.any? { |column| saved_change_to_attribute?(column) }
131
+ end
132
+
133
+ def publish_live_state_to_firestore
134
+ SpreeCmCommissioner::VotingSessionFirestorePublishJob.perform_later(id)
135
+ end
136
+ end
137
+ end