spree_cm_commissioner 2.8.6.pre.pre1 → 2.8.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +8 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/controllers/spree/admin/import_new_orders_controller.rb +25 -0
  5. data/app/controllers/spree/api/v2/storefront/account/orders_controller_decorator.rb +5 -0
  6. data/app/controllers/spree/api/v2/storefront/order_histories_controller.rb +2 -2
  7. data/app/controllers/spree/api/v2/storefront/ticket_transfers_controller.rb +4 -3
  8. data/app/controllers/spree/api/v2/tenant/free_vote_claims_controller.rb +37 -0
  9. data/app/controllers/spree/api/v2/tenant/order_histories_controller.rb +2 -2
  10. data/app/finders/spree_cm_commissioner/orders/find.rb +33 -3
  11. data/app/finders/spree_cm_commissioner/orders/find_by_all_state.rb +11 -0
  12. data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +16 -4
  13. data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +12 -0
  14. data/app/jobs/spree_cm_commissioner/cancel_import_order_job.rb +11 -0
  15. data/app/jobs/spree_cm_commissioner/idempotency_keys/prune_job.rb +20 -0
  16. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +21 -3
  17. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +34 -3
  18. data/app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb +17 -0
  19. data/app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb +5 -0
  20. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +10 -0
  21. data/app/models/concerns/spree_cm_commissioner/store_preference.rb +1 -0
  22. data/app/models/spree_cm_commissioner/idempotency_key.rb +21 -0
  23. data/app/models/spree_cm_commissioner/import.rb +3 -1
  24. data/app/models/spree_cm_commissioner/imported_order.rb +6 -0
  25. data/app/models/spree_cm_commissioner/imports/import_order.rb +21 -0
  26. data/app/models/spree_cm_commissioner/order_decorator.rb +14 -0
  27. data/app/models/spree_cm_commissioner/payment_decorator.rb +1 -0
  28. data/app/models/spree_cm_commissioner/show.rb +4 -10
  29. data/app/models/spree_cm_commissioner/user_decorator.rb +0 -12
  30. data/app/models/spree_cm_commissioner/vendor_decorator.rb +0 -1
  31. data/app/models/spree_cm_commissioner/voting_session.rb +4 -4
  32. data/app/overrides/spree/admin/products/_form/allow_gift_transfer.html.erb.deface +1 -1
  33. data/app/overrides/spree/admin/products/_form/allow_transfer.html.erb.deface +1 -1
  34. data/app/overrides/spree/admin/stores/_form/store_preferences.html.erb.deface +9 -0
  35. data/app/serializers/spree/v2/tenant/vote_package_serializer.rb +1 -4
  36. data/app/services/spree_cm_commissioner/imports/orders/cancel.rb +63 -0
  37. data/app/services/spree_cm_commissioner/imports/orders/create.rb +1 -0
  38. data/app/services/spree_cm_commissioner/telegram_alerts/checks/order_complete_payment_not_paid.rb +21 -0
  39. data/app/services/spree_cm_commissioner/telegram_alerts/checks/payment_paid_order_not_complete.rb +21 -0
  40. data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_alert.rb +112 -0
  41. data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_checks_runner.rb +32 -0
  42. data/app/services/spree_cm_commissioner/voting_credits/claim_free_votes.rb +66 -63
  43. data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +66 -24
  44. data/app/views/spree/admin/import_new_orders/_cancel_modal.html.erb +35 -0
  45. data/app/views/spree/admin/import_new_orders/index.html.erb +4 -0
  46. data/app/views/spree/admin/import_new_orders/show.html.erb +30 -0
  47. data/app/views/spree/admin/imports/index.html.erb +2 -0
  48. data/app/views/spree/admin/inventory_holds/index.html.erb +8 -3
  49. data/config/initializers/spree_permitted_attributes.rb +1 -0
  50. data/config/locales/en.yml +4 -4
  51. data/config/locales/km.yml +4 -4
  52. data/config/routes.rb +2 -2
  53. data/db/migrate/20260616000001_add_canceled_by_id_to_cm_imports.rb +5 -0
  54. data/db/migrate/20260617000002_create_cm_imported_orders.rb +12 -0
  55. data/db/migrate/20260619000001_create_cm_idempotency_keys.rb +11 -0
  56. data/lib/spree_cm_commissioner/version.rb +1 -1
  57. metadata +19 -5
  58. data/app/controllers/spree/api/v2/tenant/free_vote_claim_controller.rb +0 -58
  59. data/app/controllers/spree/api/v2/tenant/vote_packages_controller.rb +0 -33
  60. data/app/serializers/spree/v2/tenant/free_vote_claim_serializer.rb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a85bc5a3c971cde57bbe6e2549e83b5fed6e70b06cf07e46dc31eb04de53b6ca
4
- data.tar.gz: 694d562a264083048a0111ff61ddf828892ff39660c596ecc292d150df182a1e
3
+ metadata.gz: 423377b68ab98a03dc1b8c6cf94df82ed330255cd5b5249e6fbd3efd97507e96
4
+ data.tar.gz: ced4f7bb4506a6d0652a305cba71c57b117328a5e3b7a55a7cdeb3755aa4d057
5
5
  SHA512:
6
- metadata.gz: d126f5f0121b305ec904bea9370449323d8383f547373cf76440d42d18fba6c92eafa153023d61b76a679eb1239edbfe41c810c97d801b8e34db2c7f4fabf52e
7
- data.tar.gz: 67f9eecfb1909d3b7b53c91029cd242ba8f279d0fc77fb63ceac338e451b8bb71efcfbe92d6a409573a9b0955f0af9ab7edb4e509b5d880fee57631004d5c02e
6
+ metadata.gz: 41d86b9d82bbc702c8a04bff0b474c1daebbfed2cf0b0bb8b50fa8ee56358930e0cd98899d936f394ca005e62f93c92bfe8fc6cd8cbdf6d90e6db885c5a14d8e
7
+ data.tar.gz: ba9caa729146ca74e0ac1155f48ca54fa7baec727ffbf6d6ce591c872cf97688baa5b9efc7dcc1ef3e9b288ba1ea506c1d8b74b79e6e1a0cc236421fcdbdb004
data/.env.example CHANGED
@@ -22,6 +22,11 @@ WAITING_ROOM_MIN_SESSIONS_COUNT=5
22
22
  WAITING_ROOM_DISABLED=no
23
23
  WAITING_ROOM_POSITION_STAMP_LIMIT=1000
24
24
  WAITING_ROOM_FIRESTORE_BATCH_SIZE=500
25
+ WAITING_ROOM_MIN_WAIT_TO_ENTER_SECONDS=600 # Waiting Room step floor (10 min)
26
+ WAITING_ROOM_MIN_QUEUE_TO_ENTER_SECONDS=300 # Queue step floor (5 min)
27
+ WAITING_ROOM_MAX_WAIT_TO_DISPLAY_SECONDS=3600 # above this the ETA is hidden (1 h)
28
+ WAITING_ROOM_CALLER_INTERVAL_SECONDS=60 # how often the caller runs; match the waiting_guests_caller cron
29
+ WAITING_ROOM_MAX_ETA_BATCH_SIZE=50 # cap on batch size assumed when estimating the ETA
25
30
 
26
31
  # Vattanac Bank
27
32
  VATTANAC_AES_SECRET_KEY= ""
@@ -68,3 +73,6 @@ HOLD_COOLDOWN_AFTER_EXPIRY_IN_MINUTES=2 # Cooldown period before a user can
68
73
 
69
74
  # See: app/models/spree_cm_commissioner/payment_decorator.rb
70
75
  PAYMENT_INCOMPLETE_NOTIFICATION_DELAY_IN_MINUTES=5 # Delay before notifying a customer that their order is still unpaid after payment is created
76
+
77
+ # See: app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb
78
+ ORDER_INTEGRITY_TELEGRAM_ALERT_ENABLED="no" # Set to yes to enable the order integrity Telegram alert job (off by default to avoid flooding Telegram rate limits during traffic spikes)
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.8.6.pre.pre1)
37
+ spree_cm_commissioner (2.8.7.pre.pre1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -23,6 +23,31 @@ module Spree
23
23
  end
24
24
  end
25
25
  end
26
+
27
+ # POST: /admin/orders/import_new_orders/:id/cancel
28
+ def cancel
29
+ import = model_class.find(params[:id])
30
+ show_url = spree.admin_import_new_order_url(import.id)
31
+
32
+ unless import.cancellable?
33
+ flash[:error] = 'These orders cannot be canceled.' # rubocop:disable Rails/I18nLocaleTexts
34
+ redirect_to show_url and return
35
+ end
36
+
37
+ if params[:confirm_name].to_s.strip != import.name.to_s.strip
38
+ flash[:error] = 'Import name did not match. Cancellation aborted.' # rubocop:disable Rails/I18nLocaleTexts
39
+ redirect_to show_url and return
40
+ end
41
+
42
+ SpreeCmCommissioner::CancelImportOrderJob.perform_later(
43
+ import_order_id: import.id,
44
+ canceled_by_user_id: spree_current_user.id,
45
+ cancellation_reason: params[:cancellation_reason].to_s.strip.presence
46
+ )
47
+
48
+ flash[:success] = "Canceling orders from \"#{import.name}\". This runs in the background."
49
+ redirect_to show_url
50
+ end
26
51
  end
27
52
  end
28
53
  end
@@ -44,6 +44,11 @@ module Spree
44
44
  def collection_finder
45
45
  SpreeCmCommissioner::Orders::FindByState
46
46
  end
47
+
48
+ # this allows fetching a single order regardless of its state.
49
+ def resource_finder
50
+ SpreeCmCommissioner::Orders::FindByAllState
51
+ end
47
52
  end
48
53
  end
49
54
  end
@@ -31,12 +31,12 @@ module Spree
31
31
 
32
32
  def collection
33
33
  if spree_current_user.present?
34
- spree_current_user.orders.payment.not_archived
34
+ spree_current_user.orders.not_archived
35
35
  else
36
36
  order_tokens = Array(params[:order_tokens])
37
37
  return Spree::Order.none if order_tokens.empty?
38
38
 
39
- Spree::Order.payment.not_archived.without_user.where(token: order_tokens)
39
+ Spree::Order.not_archived.without_user.where(token: order_tokens)
40
40
  end
41
41
  end
42
42
 
@@ -86,9 +86,10 @@ module Spree
86
86
  end
87
87
 
88
88
  def collection
89
- SpreeCmCommissioner::TicketTransfer.includes(:order,
90
- from_guest: [:event, { line_item: :product }]
91
- ).where(from_user_id: spree_current_user.id)
89
+ SpreeCmCommissioner::TicketTransfer
90
+ .includes(:order, from_guest: [:event, { line_item: :product }])
91
+ .where(from_user_id: spree_current_user.id)
92
+ .order(id: :desc)
92
93
  end
93
94
 
94
95
  def resource_serializer
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Tenant
5
+ class FreeVoteClaimsController < BaseController
6
+ before_action :require_spree_current_user
7
+
8
+ def create
9
+ show = current_vendor.shows.find(params[:show_id])
10
+
11
+ result = SpreeCmCommissioner::VotingCredits::ClaimFreeVotes.new(
12
+ show: show,
13
+ user: spree_current_user,
14
+ tenant_id: MultiTenant.current_tenant_id,
15
+ votable_type: params[:votable_type],
16
+ votable_id: params[:votable_id]
17
+ ).call
18
+
19
+ if result.failure?
20
+ render_error_payload(result.error.to_s)
21
+ elsif result.value.nil?
22
+ head :no_content
23
+ else
24
+ render_serialized_payload { serialize_resource(result.value) }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def resource_serializer
31
+ Spree::V2::Tenant::VotingCreditSerializer
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -38,12 +38,12 @@ module Spree
38
38
 
39
39
  def collection
40
40
  if spree_current_user.present?
41
- spree_current_user.orders.payment.not_archived
41
+ spree_current_user.orders.not_archived
42
42
  else
43
43
  order_tokens = Array(params[:order_tokens])
44
44
  return Spree::Order.none if order_tokens.empty?
45
45
 
46
- Spree::Order.payment.not_archived.without_user.where(token: order_tokens)
46
+ Spree::Order.not_archived.without_user.where(token: order_tokens)
47
47
  end
48
48
  end
49
49
 
@@ -4,14 +4,30 @@
4
4
  module SpreeCmCommissioner
5
5
  module Orders
6
6
  class Find
7
+ CURRENT_CART_STATES = %w[cart address].freeze
8
+ CART_MAX_AGE_IN_MINUTES = ENV.fetch('CART_MAX_AGE_IN_MINUTES', '720').to_i # Default: 12 hours
9
+
7
10
  def execute(store:, user:, currency:, token: nil, state: nil)
11
+ state = Array(state).map(&:to_s)
12
+
8
13
  params = { store_id: store.id, currency: currency }
9
14
  params[:state] = state if state.present?
10
15
 
11
- return find_by_token(params, token) if token.present?
12
- return find_by_user(params, user) if user.present?
16
+ order = if token.present?
17
+ find_by_token(params, token)
18
+ elsif user.present?
19
+ find_by_user(params, user)
20
+ end
21
+
22
+ # Only enforce hold and age expiration when fetching current cart states ('cart' or 'address').
23
+ # For other states (like 'payment,complete'), we want to return the order even if it's old or expired
24
+ # because some pages on client still need some specific state of order to display.
25
+ if state.intersect?(CURRENT_CART_STATES)
26
+ return nil if hold_expired?(order)
27
+ return nil if cart_too_old?(order)
28
+ end
13
29
 
14
- nil
30
+ order
15
31
  end
16
32
 
17
33
  def find_by_token(params, token)
@@ -24,6 +40,20 @@ module SpreeCmCommissioner
24
40
  scope.order(created_at: :desc).find_by(params)
25
41
  end
26
42
 
43
+ def hold_expired?(order)
44
+ return false if order.nil?
45
+
46
+ # Check if the order has a hold_expires_at column and if it has already passed
47
+ order.hold_expires_at.present? && order.hold_expires_at < Time.current
48
+ end
49
+
50
+ def cart_too_old?(order)
51
+ return false if order.nil?
52
+
53
+ # Treat the cart as active based on its last update time, not its creation time.
54
+ order.updated_at < CART_MAX_AGE_IN_MINUTES.minutes.ago
55
+ end
56
+
27
57
  private
28
58
 
29
59
  def scope
@@ -0,0 +1,11 @@
1
+ module SpreeCmCommissioner
2
+ module Orders
3
+ class FindByAllState < Spree::Orders::FindComplete
4
+ private
5
+
6
+ def scope
7
+ user? ? user.orders.includes(scope_includes) : Spree::Order.includes(scope_includes)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -3,8 +3,11 @@ require 'google/cloud/firestore'
3
3
  # TODO: alert when available_slots is negative.
4
4
  module SpreeCmCommissioner
5
5
  class WaitingGuestsCaller < BaseInteractor
6
- MIN_WAIT_TO_ENTER_SECONDS = 120 # 2 min floor — Waiting Room step (full journey, more uncertainty)
7
- MIN_QUEUE_TO_ENTER_SECONDS = 60 # 1 min floor — Queue step (position assigned, one caller cycle minimum)
6
+ # 10 min floor — Waiting Room step (full journey, most uncertainty)
7
+ MIN_WAIT_TO_ENTER_SECONDS = (ENV['WAITING_ROOM_MIN_WAIT_TO_ENTER_SECONDS'] || 600).to_i
8
+
9
+ # 5 min floor — Queue step (position assigned, sub-segment of the journey)
10
+ MIN_QUEUE_TO_ENTER_SECONDS = (ENV['WAITING_ROOM_MIN_QUEUE_TO_ENTER_SECONDS'] || 300).to_i
8
11
 
9
12
  # Firestore bounds a batch update by payload size (10 MiB); 500 ops/commit leaves us far under that.
10
13
  FIRESTORE_BATCH_SIZE = (ENV['WAITING_ROOM_FIRESTORE_BATCH_SIZE'] || 500).to_i
@@ -20,7 +23,8 @@ module SpreeCmCommissioner
20
23
  full: long_waiting_guests.size >= available_slots,
21
24
  available_slots: available_slots - long_waiting_guests.size,
22
25
  avg_wait_to_enter_seconds: compute_avg_wait_to_enter_seconds(long_waiting_guests),
23
- avg_queue_to_enter_seconds: compute_avg_queue_to_enter_seconds(long_waiting_guests)
26
+ avg_queue_to_enter_seconds: compute_avg_queue_to_enter_seconds(long_waiting_guests),
27
+ slots_per_call: available_slots
24
28
  )
25
29
  end
26
30
 
@@ -96,10 +100,18 @@ module SpreeCmCommissioner
96
100
  end
97
101
 
98
102
  # merge: true so we preserve the published `waiting_guests_records_path` on the lobby doc.
99
- def mark_as(full:, available_slots:, avg_wait_to_enter_seconds: nil, avg_queue_to_enter_seconds: nil)
103
+ def mark_as(
104
+ full:,
105
+ available_slots:,
106
+ avg_wait_to_enter_seconds: nil,
107
+ avg_queue_to_enter_seconds: nil,
108
+ slots_per_call: nil
109
+ )
100
110
  data = { full: full, available_slots: available_slots }
101
111
  data[:avg_wait_to_enter_seconds] = avg_wait_to_enter_seconds if avg_wait_to_enter_seconds
102
112
  data[:avg_queue_to_enter_seconds] = avg_queue_to_enter_seconds if avg_queue_to_enter_seconds
113
+ data[:slots_per_call] = slots_per_call if slots_per_call
114
+
103
115
  lobby_document.set(data, merge: true)
104
116
  end
105
117
 
@@ -0,0 +1,12 @@
1
+ module SpreeCmCommissioner
2
+ module IdempotentJob
3
+ def with_idempotency(key = nil, &block)
4
+ SpreeCmCommissioner::IdempotencyKey.run_once(key.presence || default_idempotency_key, &block)
5
+ end
6
+
7
+ # Namespaced so the key is self-describing in the DB (no `source` column needed).
8
+ def default_idempotency_key
9
+ "#{self.class.name}:#{job_id}"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module SpreeCmCommissioner
2
+ class CancelImportOrderJob < ApplicationUniqueJob
3
+ def perform(options = {})
4
+ SpreeCmCommissioner::Imports::Orders::Cancel.new(
5
+ import_order_id: options[:import_order_id],
6
+ canceled_by_user_id: options[:canceled_by_user_id],
7
+ cancellation_reason: options[:cancellation_reason]
8
+ ).call
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ # Prunes old idempotency keys. Keys only matter within the retry window
2
+ # (minutes–hours), so anything older than the cutoff is safe to delete.
3
+ # The `created_at` index supports the delete.
4
+ module SpreeCmCommissioner
5
+ module IdempotencyKeys
6
+ class PruneJob < SpreeCmCommissioner::ApplicationJob
7
+ DEFAULT_CUTOFF_DAYS = 3
8
+
9
+ queue_as :default
10
+
11
+ def perform(cutoff_days = DEFAULT_CUTOFF_DAYS)
12
+ days = [cutoff_days.to_i, 1].max
13
+
14
+ SpreeCmCommissioner::IdempotencyKey
15
+ .where(created_at: ...days.days.ago)
16
+ .delete_all
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,12 +1,30 @@
1
1
  module SpreeCmCommissioner
2
2
  module InventoryItems
3
3
  class BulkAdjustQuantitiesJob < ApplicationUniqueJob
4
+ include SpreeCmCommissioner::IdempotentJob
5
+
6
+ def perform(options = {})
7
+ with_idempotency(idempotency_key_for(options)) do
8
+ bulk_adjust_quantities!(options)
9
+ end
10
+ end
11
+
12
+ private
13
+
14
+ # Dedup on the line items being synced, so a re-pushed payload for the same
15
+ # line items no-ops. An explicit key wins; with no line items we fall back to
16
+ # the per-enqueue job_id (returning nil lets with_idempotency apply that default).
17
+ def idempotency_key_for(options)
18
+ return options[:idempotency_key] if options[:idempotency_key].present?
19
+ return nil if options[:line_item_ids].blank?
20
+
21
+ "line_item:#{options[:line_item_ids].compact.uniq.sort.join(':')}"
22
+ end
23
+
4
24
  # :line_item_ids, :inventory_id_and_quantities, :caller_source
5
25
  #
6
- # :line_item_ids is included for unique job key generation to prevent duplicate jobs,
7
- # though it's not used in the perform method implementation.
8
26
  # :caller_source is a string like "ClassName#method" identifying who enqueued the job.
9
- def perform(options = {})
27
+ def bulk_adjust_quantities!(options = {})
10
28
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantities.call!(
11
29
  inventory_id_and_quantities: options[:inventory_id_and_quantities],
12
30
  caller_source: options[:caller_source]
@@ -1,14 +1,45 @@
1
1
  module SpreeCmCommissioner
2
2
  module InventoryItems
3
3
  class BulkAdjustQuantitiesOnHoldJob < ApplicationUniqueJob
4
+ include SpreeCmCommissioner::IdempotentJob
5
+
4
6
  queue_as :default
5
7
 
8
+ def perform(options = {})
9
+ with_idempotency(idempotency_key_for(options)) do
10
+ bulk_adjust_quantities_on_hold(options)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ # Dedup on the order + the exact adjustment set, so a re-pushed identical
17
+ # on-hold payload no-ops while a genuinely different one re-applies. An explicit
18
+ # key wins; with no order_id we fall back to the per-enqueue job_id (returning
19
+ # nil lets with_idempotency apply that default).
20
+ def idempotency_key_for(options)
21
+ return options[:idempotency_key] if options[:idempotency_key].present?
22
+ return nil if options[:order_id].blank?
23
+
24
+ "order:#{options[:order_id]}:hold:#{adjustments_fingerprint(options[:inventory_id_and_quantities])}"
25
+ end
26
+
27
+ # Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1".
28
+ # Delimiter hierarchy: ":" separates key fields, "," separates adjustments, "=" pairs id/qty.
29
+ def adjustments_fingerprint(adjustments)
30
+ Array(adjustments)
31
+ .map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity) }
32
+ .sort_by { |inventory_id, quantity| [inventory_id.to_s, quantity.to_s] }
33
+ .map { |inventory_id, quantity| "#{inventory_id}=#{quantity}" }
34
+ .join(',')
35
+ end
36
+
6
37
  # :order_id, :inventory_id_and_quantities, :caller_source
7
38
  #
8
- # :order_id is included for unique job key generation to prevent duplicate jobs,
9
- # though it's not used in the perform method implementation.
39
+ # :order_id is used for unique idempotency key generation to prevent duplicate
40
+ # processing, though it's not used in the adjustment itself.
10
41
  # :caller_source is a string like "ClassName#method" identifying who enqueued the job.
11
- def perform(options = {})
42
+ def bulk_adjust_quantities_on_hold(options = {})
12
43
  raise ArgumentError, 'order_id is required' if options[:order_id].blank?
13
44
  raise ArgumentError, 'inventory_id_and_quantities is required' if options[:inventory_id_and_quantities].blank?
14
45
 
@@ -0,0 +1,17 @@
1
+ module SpreeCmCommissioner
2
+ module TelegramAlerts
3
+ class OrderIntegrityCheckJob < ApplicationUniqueJob
4
+ queue_as :telegram_bot
5
+ # retry: 0 sends failed jobs straight to the dead queue, avoiding retry storms.
6
+ # Guarded since sidekiq_options requires the host app's Sidekiq integration.
7
+ sidekiq_options retry: 0 if respond_to?(:sidekiq_options)
8
+
9
+ def perform(order_id:)
10
+ order = Spree::Order.find_by(id: order_id)
11
+ return unless order
12
+
13
+ SpreeCmCommissioner::TelegramAlerts::OrderIntegrityChecksRunner.call(order: order)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,6 +1,11 @@
1
+ # Scheduled as a cron job (see cm-market-server/config/schedule.yml):
1
2
  # waiting_guests_caller:
2
3
  # cron: "*/1 * * * *" # Every minute
3
4
  # class: "SpreeCmCommissioner::WaitingGuestsCallerJob"
5
+ #
6
+ # NOTE: the queue ETA assumes one batch is released per run, so this interval must match
7
+ # SpreeCmCommissioner::WaitingRoom::StampQueuePositions::CALLER_INTERVAL_SECONDS
8
+ # (env WAITING_ROOM_CALLER_INTERVAL_SECONDS). If you change the cron, update that too.
4
9
  module SpreeCmCommissioner
5
10
  class WaitingGuestsCallerJob < ApplicationUniqueJob
6
11
  queue_as :waiting_room
@@ -30,6 +30,7 @@ module SpreeCmCommissioner
30
30
 
31
31
  state_machine.after_transition to: :complete, do: :increment_route_fulfilled_order_count
32
32
  state_machine.after_transition to: :complete, do: :allocate_resources
33
+ state_machine.after_transition to: :complete, do: :run_order_complete_alerts
33
34
 
34
35
  scope :accepted, -> { where(request_state: 'accepted') }
35
36
 
@@ -324,6 +325,15 @@ module SpreeCmCommissioner
324
325
  TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
325
326
  end
326
327
 
328
+ def run_order_complete_alerts
329
+ return unless ENV['ORDER_INTEGRITY_TELEGRAM_ALERT_ENABLED'] == 'yes'
330
+
331
+ # Allow async payment processors time to settle before checking for alerts
332
+ SpreeCmCommissioner::TelegramAlerts::OrderIntegrityCheckJob
333
+ .set(wait: 2.minutes)
334
+ .perform_later(order_id: id)
335
+ end
336
+
327
337
  def send_order_complete_telegram_alert_to_store
328
338
  title = '🎫 --- [NEW ORDER] ---'
329
339
  chat_id = store.preferred_telegram_order_alert_chat_id
@@ -8,6 +8,7 @@ module SpreeCmCommissioner
8
8
  preference :telegram_order_request_alert_chat_id, :string
9
9
  preference :telegram_new_vendor_alert_chat_id, :string
10
10
  preference :telegram_new_event_alert_chat_id, :string
11
+ preference :telegram_order_anomaly_alert_chat_id, :string
11
12
  preference :assetlinks, :string, default: ''
12
13
  preference :apple_app_site_association, :string, default: ''
13
14
  end
@@ -0,0 +1,21 @@
1
+ module SpreeCmCommissioner
2
+ class IdempotencyKey < Base
3
+ # Runs the block exactly once per key. A re-run finds the row and no-ops.
4
+ # Key row + block side effects commit atomically. `requires_new: true` opens a
5
+ # savepoint so a rescued insert race rolls back only this block, not an outer txn.
6
+ def self.run_once(key)
7
+ return yield if key.blank?
8
+
9
+ transaction(requires_new: true) do
10
+ create!(key: key)
11
+ yield
12
+ end
13
+ rescue ActiveRecord::RecordNotUnique
14
+ CmAppLogger.log(
15
+ label: 'SpreeCmCommissioner::IdempotencyKey.run_once',
16
+ data: { event: 'duplicate_skipped', key: key }
17
+ )
18
+ nil # already applied by a concurrent run
19
+ end
20
+ end
21
+ end
@@ -2,12 +2,14 @@ module SpreeCmCommissioner
2
2
  class Import < Base
3
3
  extend FriendlyId
4
4
 
5
- enum :status, { :queue => 0, :progress => 1, :done => 2, :failed => 3 }
5
+ enum :status, { :queue => 0, :progress => 1, :done => 2, :failed => 3, :canceling => 4, :canceled => 5 }
6
6
  enum :import_type, { :new_order => 0, :existing_order => 1, :aba_payment_reference => 2, :contestant_import => 3 }
7
+
7
8
  has_one_attached :imported_file
8
9
  friendly_id :name, use: :slugged
9
10
 
10
11
  belongs_to :importable, polymorphic: true, optional: true
11
12
  belongs_to :import_by, class_name: 'Spree::User', optional: true
13
+ belongs_to :canceled_by, class_name: 'Spree::User', optional: true
12
14
  end
13
15
  end
@@ -0,0 +1,6 @@
1
+ module SpreeCmCommissioner
2
+ class ImportedOrder < Base
3
+ belongs_to :order, class_name: 'Spree::Order'
4
+ belongs_to :import_order, class_name: 'SpreeCmCommissioner::Imports::ImportOrder'
5
+ end
6
+ end
@@ -2,6 +2,27 @@ module SpreeCmCommissioner
2
2
  module Imports
3
3
  class ImportOrder < Import
4
4
  preference :fail_rows, :string
5
+ # Stored as ISO8601 string — Spree's preferences have no :datetime cast.
6
+ preference :canceled_at, :string
7
+ preference :cancellation_reason, :string
8
+
9
+ has_many :imported_orders,
10
+ class_name: 'SpreeCmCommissioner::ImportedOrder',
11
+ dependent: :destroy,
12
+ inverse_of: :import_order
13
+ has_many :orders, through: :imported_orders, source: :order
14
+
15
+ # done → order rows were created; cancel will reverse them.
16
+ # failed → may have created some orders before failing; cancel cleans those up.
17
+ def cancellable?
18
+ done? || failed?
19
+ end
20
+
21
+ def canceled_at
22
+ return nil if preferred_canceled_at.blank?
23
+
24
+ Time.zone.parse(preferred_canceled_at)
25
+ end
5
26
  end
6
27
  end
7
28
  end
@@ -31,6 +31,10 @@ module SpreeCmCommissioner
31
31
 
32
32
  base.has_one :invoice, dependent: :destroy, class_name: 'SpreeCmCommissioner::Invoice'
33
33
  base.has_one :customer, class_name: 'SpreeCmCommissioner::Customer', through: :subscription
34
+ base.has_one :imported_order,
35
+ class_name: 'SpreeCmCommissioner::ImportedOrder',
36
+ dependent: :destroy,
37
+ inverse_of: :order
34
38
 
35
39
  base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant', optional: true
36
40
  base.belongs_to :subscription, class_name: 'SpreeCmCommissioner::Subscription', optional: true
@@ -80,6 +84,16 @@ module SpreeCmCommissioner
80
84
  end
81
85
  end
82
86
 
87
+ # override: imported orders are bulk-loaded from CSV and the contact email
88
+ # often belongs to a third party (organizer, importer) rather than the buyer,
89
+ # so the cancellation email would notify the wrong recipient. Normal orders
90
+ # still receive the cancel email via the spree_emails decorator.
91
+ def send_cancel_email
92
+ return if imported_order.present?
93
+
94
+ super
95
+ end
96
+
83
97
  # override spree_core behavior to intentionally avoid calling `next!`.
84
98
  #
85
99
  # Goal: keep the order state at 'cart' when restarting checkout, especially
@@ -39,6 +39,7 @@ module SpreeCmCommissioner
39
39
  def after_completed
40
40
  super
41
41
  complete_ticket_transfer if ticket_transfer_completable?
42
+ order.run_order_complete_alerts
42
43
  end
43
44
 
44
45
  def can_void?
@@ -2,17 +2,14 @@ module SpreeCmCommissioner
2
2
  class Show < Spree::Taxon
3
3
  include SpreeCmCommissioner::StoreMetadata
4
4
 
5
- belongs_to :show, class_name: 'SpreeCmCommissioner::Show', foreign_key: :parent_id, optional: true
6
-
7
5
  has_many :voting_credits, class_name: 'SpreeCmCommissioner::VotingCredit', as: :votable, dependent: :destroy
6
+ belongs_to :show, class_name: 'SpreeCmCommissioner::Show', foreign_key: :parent_id, optional: true
8
7
  has_many :seasons, class_name: 'SpreeCmCommissioner::Show', foreign_key: :parent_id, inverse_of: :parent
9
8
  has_many :show_contestants, class_name: 'SpreeCmCommissioner::ShowContestant', inverse_of: :show, dependent: :destroy
10
9
  has_many :show_people_assignments, class_name: 'SpreeCmCommissioner::ShowPersonAssignment', dependent: :destroy
11
10
  has_many :show_people, through: :show_people_assignments, source: :show_person
12
11
  has_many :episodes, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
13
- has_many :vote_packages, -> { where(product_type: :vote_package) }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
14
12
  has_many :voting_sessions, through: :episodes, class_name: 'SpreeCmCommissioner::VotingSession'
15
-
16
13
  has_one :current_episode, -> { current_or_next_upcoming }, class_name: 'SpreeCmCommissioner::ShowEpisode', foreign_key: :event_id
17
14
  has_one :current_voting_session, through: :current_episode
18
15
 
@@ -27,8 +24,6 @@ module SpreeCmCommissioner
27
24
  SHOW_TYPES = %w[show tournament boxing].freeze
28
25
 
29
26
  # Define standard voting configuration keys
30
- attr_accessor :claimed, :effective_free_vote_limit, :effective_free_vote_limit_type
31
-
32
27
  store_accessor :voting_config,
33
28
  :free_vote_limit,
34
29
  :free_vote_limit_type,
@@ -69,13 +64,12 @@ module SpreeCmCommissioner
69
64
  "SpreeCmCommissioner::#{type}"
70
65
  end
71
66
 
72
- def free_vote_idempotency_key(user_id:, votable_id: nil, limit_type: nil)
73
- effective_limit_type = limit_type || free_vote_limit_type
74
- case effective_limit_type
67
+ def free_vote_idempotency_key(user_id:, votable_id: nil)
68
+ case free_vote_limit_type
75
69
  when 'per_episode' then "free_claim::episode::#{votable_id}::user::#{user_id}"
76
70
  when 'per_voting_session' then "free_claim::voting_session::#{votable_id}::user::#{user_id}"
77
71
  when 'per_show' then "free_claim::show::#{id}::user::#{user_id}"
78
- else raise ArgumentError, "Invalid free_vote_limit_type: #{effective_limit_type}"
72
+ else raise ArgumentError, "Invalid free_vote_limit_type: #{free_vote_limit_type}"
79
73
  end
80
74
  end
81
75