spree_cm_commissioner 2.8.6 → 2.8.7

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 (50) 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/order_histories_controller.rb +2 -2
  9. data/app/finders/spree_cm_commissioner/orders/find.rb +33 -3
  10. data/app/finders/spree_cm_commissioner/orders/find_by_all_state.rb +11 -0
  11. data/app/interactors/spree_cm_commissioner/waiting_guests_caller.rb +16 -4
  12. data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +12 -0
  13. data/app/jobs/spree_cm_commissioner/cancel_import_order_job.rb +11 -0
  14. data/app/jobs/spree_cm_commissioner/idempotency_keys/prune_job.rb +20 -0
  15. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +21 -3
  16. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +34 -3
  17. data/app/jobs/spree_cm_commissioner/telegram_alerts/order_integrity_check_job.rb +17 -0
  18. data/app/jobs/spree_cm_commissioner/waiting_guests_caller_job.rb +5 -0
  19. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +10 -0
  20. data/app/models/concerns/spree_cm_commissioner/store_preference.rb +1 -0
  21. data/app/models/spree_cm_commissioner/idempotency_key.rb +21 -0
  22. data/app/models/spree_cm_commissioner/import.rb +2 -1
  23. data/app/models/spree_cm_commissioner/imported_order.rb +6 -0
  24. data/app/models/spree_cm_commissioner/imports/import_order.rb +21 -0
  25. data/app/models/spree_cm_commissioner/order_decorator.rb +14 -0
  26. data/app/models/spree_cm_commissioner/payment_decorator.rb +1 -0
  27. data/app/overrides/spree/admin/products/_form/allow_gift_transfer.html.erb.deface +1 -1
  28. data/app/overrides/spree/admin/products/_form/allow_transfer.html.erb.deface +1 -1
  29. data/app/overrides/spree/admin/stores/_form/store_preferences.html.erb.deface +9 -0
  30. data/app/services/spree_cm_commissioner/imports/orders/cancel.rb +63 -0
  31. data/app/services/spree_cm_commissioner/imports/orders/create.rb +1 -0
  32. data/app/services/spree_cm_commissioner/telegram_alerts/checks/order_complete_payment_not_paid.rb +21 -0
  33. data/app/services/spree_cm_commissioner/telegram_alerts/checks/payment_paid_order_not_complete.rb +21 -0
  34. data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_alert.rb +112 -0
  35. data/app/services/spree_cm_commissioner/telegram_alerts/order_integrity_checks_runner.rb +32 -0
  36. data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +66 -24
  37. data/app/views/spree/admin/import_new_orders/_cancel_modal.html.erb +35 -0
  38. data/app/views/spree/admin/import_new_orders/index.html.erb +4 -0
  39. data/app/views/spree/admin/import_new_orders/show.html.erb +30 -0
  40. data/app/views/spree/admin/imports/index.html.erb +2 -0
  41. data/app/views/spree/admin/inventory_holds/index.html.erb +8 -3
  42. data/config/initializers/spree_permitted_attributes.rb +1 -0
  43. data/config/locales/en.yml +4 -4
  44. data/config/locales/km.yml +4 -4
  45. data/config/routes.rb +1 -0
  46. data/db/migrate/20260616000001_add_canceled_by_id_to_cm_imports.rb +5 -0
  47. data/db/migrate/20260617000002_create_cm_imported_orders.rb +12 -0
  48. data/db/migrate/20260619000001_create_cm_idempotency_keys.rb +11 -0
  49. data/lib/spree_cm_commissioner/version.rb +1 -1
  50. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4099b82b9c0b05292b810ed04e00714e847c0f9757912ccfdb2fe9f98d0e79a9
4
- data.tar.gz: 4047434d7d6aebaeca53abe171efbb02df51651cd31fb19824effc88e6dbaf0b
3
+ metadata.gz: 4f8d11251736536036263c451dde5cb82e34a6d54b12fe116866a338346d8097
4
+ data.tar.gz: f1cec56cfe9db369b4bf70a40c8545e09fd2b89a9d0fd1e2d0547cf4818a04eb
5
5
  SHA512:
6
- metadata.gz: 3834ecd9e3496a49180c8be39c5bf8e4b81828f1f3a9dab73122fc772e63264e8f1d6a1ce30c634223e910b696bcedb30ecfcf1fe60ace739f1c1dc725a119df
7
- data.tar.gz: b47b5250978bdd1eebcebcc74329f79ae74837a86d3167006539b68d73dc8835b81c03d877ae46441e939667f913dcf615c9ffeedccc21d980cda1e953eb96c1
6
+ metadata.gz: b0d1ef1dca4921e6493b8455b31c31b9289444aa64b3d5378dc6d87c0676c081ee2c0148dfd3a46fb8572d30fb8fb86d6c53a24249768b3b9001fbb3f2f6a4fa
7
+ data.tar.gz: a3dfc479c74f38e3889e84ab72246fc01e31134a630563f4f3bf2b3713e3e13d2df1389a0bdf1aae4337e56d891cb6a6dc29e21570d28b84d9d75a5b1d1cd152
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)
37
+ spree_cm_commissioner (2.8.7)
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
@@ -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
@@ -28,6 +28,7 @@ module SpreeCmCommissioner
28
28
  state_machine.after_transition to: :canceled, do: :restock_inventory!
29
29
 
30
30
  state_machine.after_transition to: :complete, do: :increment_route_fulfilled_order_count
31
+ state_machine.after_transition to: :complete, do: :run_order_complete_alerts
31
32
 
32
33
  scope :accepted, -> { where(request_state: 'accepted') }
33
34
 
@@ -322,6 +323,15 @@ module SpreeCmCommissioner
322
323
  TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
323
324
  end
324
325
 
326
+ def run_order_complete_alerts
327
+ return unless ENV['ORDER_INTEGRITY_TELEGRAM_ALERT_ENABLED'] == 'yes'
328
+
329
+ # Allow async payment processors time to settle before checking for alerts
330
+ SpreeCmCommissioner::TelegramAlerts::OrderIntegrityCheckJob
331
+ .set(wait: 2.minutes)
332
+ .perform_later(order_id: id)
333
+ end
334
+
325
335
  def send_order_complete_telegram_alert_to_store
326
336
  title = '🎫 --- [NEW ORDER] ---'
327
337
  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,13 @@ 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 }
7
7
  has_one_attached :imported_file
8
8
  friendly_id :name, use: :slugged
9
9
 
10
10
  belongs_to :importable, polymorphic: true, optional: true
11
11
  belongs_to :import_by, class_name: 'Spree::User', optional: true
12
+ belongs_to :canceled_by, class_name: 'Spree::User', optional: true
12
13
  end
13
14
  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?
@@ -1,4 +1,4 @@
1
- <!-- insert_after "[data-hook='admin_product_form_use_video_as_default']" -->
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
2
 
3
3
  <div data-hook="admin_product_form_allow_gift_transfer">
4
4
  <%= f.field_container :allow_gift_transfer do %>
@@ -1,4 +1,4 @@
1
- <!-- insert_after "[data-hook='admin_product_form_use_video_as_default']" -->
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
2
 
3
3
  <div data-hook="admin_product_form_allow_transfer">
4
4
  <%= f.field_container :allow_transfer do %>
@@ -55,6 +55,15 @@
55
55
  <% end %>
56
56
  </div>
57
57
 
58
+ <!-- Telegram Order Anomaly Alert Chat ID -->
59
+ <div class="col-6">
60
+ <%= f.field_container :preferred_telegram_order_anomaly_alert_chat_id do %>
61
+ <%= f.label :preferred_telegram_order_anomaly_alert_chat_id, Spree.t(:telegram_order_anomaly_alert_chat_id) %>
62
+ <%= f.text_field :preferred_telegram_order_anomaly_alert_chat_id, class: 'form-control' %>
63
+ <%= f.error_message_on :preferred_telegram_order_anomaly_alert_chat_id %>
64
+ <% end %>
65
+ </div>
66
+
58
67
  <!-- Assetlinks -->
59
68
  <div class="col-12">
60
69
  <%= f.field_container :preferred_assetlinks do %>
@@ -0,0 +1,63 @@
1
+ module SpreeCmCommissioner
2
+ module Imports
3
+ module Orders
4
+ class Cancel < Base
5
+ BATCH_SIZE = ENV.fetch('CANCEL_IMPORT_ORDERS_BATCH_SIZE', '50').to_i
6
+
7
+ attr_reader :canceled_by_user_id, :cancellation_reason
8
+
9
+ def initialize(import_order_id:, canceled_by_user_id:, cancellation_reason: nil)
10
+ super(import_order_id: import_order_id)
11
+ @canceled_by_user_id = canceled_by_user_id
12
+ @cancellation_reason = cancellation_reason
13
+ end
14
+
15
+ def call
16
+ return if import_order.canceled?
17
+
18
+ mark_canceling!
19
+ cancel_orders
20
+ mark_canceled!
21
+ end
22
+
23
+ def cancel_orders
24
+ import_order.orders.includes(:imported_order).where.not(state: 'canceled').find_in_batches(batch_size: BATCH_SIZE) do |batch|
25
+ batch.each do |order|
26
+ order.canceled_by(canceled_by_user, cancellation_reason: cancellation_reason)
27
+ rescue StandardError => e
28
+ CmAppLogger.error(
29
+ label: "#{self.class.name}#cancel_orders",
30
+ data: { message: e.message, order_id: order.id, order_number: order.number }
31
+ )
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def canceled_by_user
39
+ @canceled_by_user ||= Spree::User.find(canceled_by_user_id)
40
+ end
41
+
42
+ def mark_canceling!
43
+ # Skip on Sidekiq retry — keep the original canceler attribution.
44
+ return if import_order.canceling?
45
+
46
+ import_order.update!(
47
+ status: :canceling,
48
+ canceled_by_id: canceled_by_user_id,
49
+ preferred_canceled_at: Time.zone.now.iso8601,
50
+ preferred_cancellation_reason: cancellation_reason
51
+ )
52
+ end
53
+
54
+ def mark_canceled!
55
+ import_order.update!(
56
+ status: :canceled,
57
+ finished_at: Time.zone.now
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end