spree_cm_commissioner 2.14.6 → 2.14.8

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +9 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/guest_owner_authorization.rb +30 -0
  5. data/app/controllers/spree/admin/stock_managements_controller.rb +12 -1
  6. data/app/controllers/spree/api/v2/organizer/invites_controller.rb +5 -1
  7. data/app/controllers/spree/api/v2/storefront/guests_controller.rb +13 -7
  8. data/app/controllers/spree/api/v2/storefront/id_cards_controller.rb +6 -4
  9. data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +1 -1
  10. data/app/controllers/spree/api/v2/storefront/s3_signed_urls_controller.rb +4 -3
  11. data/app/controllers/spree/api/v2/tenant/base_controller.rb +23 -3
  12. data/app/controllers/spree/api/v2/tenant/cart_controller.rb +1 -1
  13. data/app/controllers/spree/api/v2/tenant/id_cards_controller.rb +6 -4
  14. data/app/controllers/spree/api/v2/tenant/payment_methods_controller.rb +1 -2
  15. data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
  16. data/app/controllers/spree/api/v2/tenant/s3_signed_urls_controller.rb +4 -3
  17. data/app/interactors/spree_cm_commissioner/guest_id_card_manager.rb +18 -9
  18. data/app/interactors/spree_cm_commissioner/id_card_image_updater.rb +6 -3
  19. data/app/interactors/spree_cm_commissioner/image_saver.rb +4 -3
  20. data/app/interactors/spree_cm_commissioner/profile_image_updater.rb +6 -3
  21. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -9
  22. data/app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb +44 -0
  23. data/app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb +17 -0
  24. data/app/models/spree_cm_commissioner/back_image.rb +2 -0
  25. data/app/models/spree_cm_commissioner/front_image.rb +2 -0
  26. data/app/models/spree_cm_commissioner/integration.rb +1 -1
  27. data/app/models/spree_cm_commissioner/integration_mapping.rb +27 -0
  28. data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +1 -0
  29. data/app/models/spree_cm_commissioner/order_decorator.rb +11 -0
  30. data/app/models/spree_cm_commissioner/product_decorator.rb +4 -0
  31. data/app/models/spree_cm_commissioner/s3_presigned_url.rb +8 -7
  32. data/app/models/spree_cm_commissioner/s3_presigned_url_builder.rb +9 -3
  33. data/app/models/spree_cm_commissioner/tenant.rb +3 -0
  34. data/app/models/spree_cm_commissioner/user_profile.rb +2 -0
  35. data/app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface +13 -0
  36. data/app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface +12 -0
  37. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +4 -1
  38. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +28 -17
  39. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +5 -7
  40. data/app/services/spree_cm_commissioner/draft_orders/create.rb +6 -3
  41. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +30 -4
  42. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_inventory.rb +33 -11
  43. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +3 -2
  44. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +7 -3
  45. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +44 -32
  46. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +1 -1
  47. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +1 -1
  48. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +12 -1
  49. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +2 -2
  50. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +1 -1
  51. data/app/services/spree_cm_commissioner/integrations/larryta/polling/sync_product.rb +2 -1
  52. data/app/services/spree_cm_commissioner/integrations/vireak_buntham/polling/sync_product.rb +2 -1
  53. data/app/views/spree/admin/integrations/_book_me_bus_v1_fields.html.erb +10 -0
  54. data/app/views/spree/admin/integrations/_form.html.erb +1 -1
  55. data/config/initializers/spree_permitted_attributes.rb +4 -0
  56. data/config/locales/en.yml +2 -0
  57. data/config/locales/km.yml +1 -0
  58. data/config/routes.rb +14 -6
  59. data/db/migrate/20261103000001_add_sales_channel_to_spree_products.rb +14 -0
  60. data/lib/spree_cm_commissioner/s3_url_generator.rb +13 -1
  61. data/lib/spree_cm_commissioner/tenant_slug_constraint.rb +23 -0
  62. data/lib/spree_cm_commissioner/version.rb +1 -1
  63. data/lib/spree_cm_commissioner.rb +1 -0
  64. metadata +9 -9
  65. data/app/controllers/spree/api/v2/organizer/images_controller.rb +0 -56
  66. data/app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb +0 -29
  67. data/app/controllers/spree/api/v2/organizer/ticket_images_controller.rb +0 -27
  68. data/app/controllers/spree/api/v2/organizer/tickets_controller.rb +0 -97
  69. data/app/serializers/spree/v2/organizer/image_serializer.rb +0 -11
  70. data/app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb +0 -10
  71. data/app/serializers/spree/v2/organizer/ticket_serializer.rb +0 -9
@@ -6,6 +6,7 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1 < SpreeCmCommissioner::Inte
6
6
  store_private_metadata :access_token, :string
7
7
  store_private_metadata :token_expires_at, :datetime
8
8
  store_private_metadata :operator_id, :string
9
+ store_private_metadata :sync_days, :integer, default: 7
9
10
  # override
10
11
  def sync_manager
11
12
  SpreeCmCommissioner::Integrations::BookMeBusV1::SyncManager.new(integration: self)
@@ -190,6 +190,17 @@ module SpreeCmCommissioner
190
190
  super
191
191
  end
192
192
 
193
+ # Must live here, not in OrderStateMachine: that concern is included (below Spree::Order),
194
+ # while Spree::Emails::OrderDecorator is prepended above it and never calls super, so an
195
+ # override there is silently dead.
196
+ def deliver_order_confirmation_email
197
+ return if requested?
198
+ return if any_app_only_products?
199
+ return if products.none?(&:enable_confirmation_email?)
200
+
201
+ super
202
+ end
203
+
193
204
  def any_app_only_products?
194
205
  if products.loaded?
195
206
  products.any?(&:purchasable_on_app?)
@@ -143,6 +143,7 @@ module SpreeCmCommissioner
143
143
  base.store_public_metadata :enable_inventory_hold, :boolean, default: false
144
144
  base.store_public_metadata :enable_telegram_alert, :boolean, default: true
145
145
  base.store_public_metadata :enable_send_receipt, :boolean, default: true
146
+ base.store_public_metadata :enable_confirmation_email, :boolean, default: true
146
147
  base.store_public_metadata :enable_social_sharing_email, :boolean, default: false
147
148
 
148
149
  base.store_private_metadata :advertise_weight, :integer, default: 1
@@ -169,6 +170,9 @@ module SpreeCmCommissioner
169
170
  base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant'
170
171
  base.before_save :set_tenant
171
172
 
173
+ # Included after set_tenant so its before_save sees the resolved tenant_id.
174
+ base.include SpreeCmCommissioner::SalesChannelBitwise
175
+
172
176
  # Create maintaining task to purge product related caches
173
177
  base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
174
178
 
@@ -11,25 +11,26 @@ module SpreeCmCommissioner
11
11
  URI.parse(url).host
12
12
  end
13
13
 
14
- def initialize(bucket_name, object_key, uuid = nil)
14
+ # content_length_range: [Range, nil] byte range S3 enforces on the upload itself; nil means no limit.
15
+ def initialize(bucket_name, object_key, uuid = nil, content_length_range: nil)
15
16
  @id = uuid.presence || generate_secure_random
16
17
 
17
18
  if object_key.blank?
18
19
  @error_message = I18n.t('s3_signed_url.missing_file_name')
19
20
  else
20
- @s3_direct_post = presigned_post(bucket_name, object_key)
21
+ @s3_direct_post = presigned_post(bucket_name, object_key, content_length_range)
21
22
  end
22
23
  end
23
24
 
24
25
  private
25
26
 
26
- def presigned_post(bucket_name, object_key)
27
+ def presigned_post(bucket_name, object_key, content_length_range)
28
+ options = { key: object_key, success_action_status: '201' }
29
+ options[:content_length_range] = content_length_range if content_length_range
30
+
27
31
  Aws::S3::Resource.new
28
32
  .bucket(bucket_name)
29
- .presigned_post(
30
- key: object_key,
31
- success_action_status: '201'
32
- )
33
+ .presigned_post(**options)
33
34
  end
34
35
 
35
36
  def generate_secure_random
@@ -6,13 +6,15 @@ module SpreeCmCommissioner
6
6
  new(options).call
7
7
  end
8
8
 
9
- # {:bucket_name?, (:object_key | :file_name)? }
9
+ # {:bucket_name?, (:object_key | :file_name)?, :content_length_range? }
10
+ # bucket_name/object_key are for trusted server-side callers only (admin media uploads); API
11
+ # controllers must pass just file_name, so callers can only write under uploads/<uuid>/.
10
12
  def initialize(options)
11
13
  @options = options
12
14
  end
13
15
 
14
16
  def call
15
- SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid)
17
+ SpreeCmCommissioner::S3PresignedUrl.new(bucket_name, object_key, uuid, content_length_range: options[:content_length_range])
16
18
  end
17
19
 
18
20
  private
@@ -20,7 +22,11 @@ module SpreeCmCommissioner
20
22
  def object_key
21
23
  return options[:object_key] if options[:object_key].present?
22
24
 
23
- "uploads/#{uuid}/#{options[:file_name]}"
25
+ # basename so a file_name like "../x" or "a/b" can't move the key out of its uuid folder.
26
+ file_name = File.basename(options[:file_name].to_s)
27
+ return nil if file_name.blank? || %w[. .. /].include?(file_name)
28
+
29
+ "uploads/#{uuid}/#{file_name}"
24
30
  end
25
31
 
26
32
  def bucket_name
@@ -21,6 +21,9 @@ module SpreeCmCommissioner
21
21
  allow_blank: true,
22
22
  format: { without: %r{\Ahttps?://}, message: :format }
23
23
 
24
+ # The slug is the first segment of /api/v2/:tenant_slug/..., so it must not shadow a v2 namespace.
25
+ validates :slug, exclusion: { in: SpreeCmCommissioner::TenantSlugConstraint::RESERVED_SLUGS }
26
+
24
27
  validates :sms_sender_id, length: { maximum: 11 }, allow_blank: true
25
28
 
26
29
  validates :default_currency, inclusion: { in: -> (tenant) { tenant.supported_currencies_list } }, allow_blank: true
@@ -1,5 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class UserProfile < Asset
3
+ include SpreeCmCommissioner::UserUploadedImage
4
+
3
5
  protected
4
6
 
5
7
  def asset_styles
@@ -0,0 +1,13 @@
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
+
3
+ <div>
4
+ <%= f.field_container :enable_confirmation_email do %>
5
+ <%= f.label :enable_confirmation_email do %>
6
+ <%= f.check_box :enable_confirmation_email %>
7
+ <%= Spree.t(:enable_confirmation_email) %>
8
+ <% end %>
9
+ <small class="form-text text-muted">
10
+ When enabled, customers receive the booking confirmation email after their order is completed.
11
+ </small>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,12 @@
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
+
3
+ <%# BookMe+'s own products are always sold on the platform, so only tenant products get the choice. %>
4
+ <% if f.object.vendor&.tenant_id.present? %>
5
+ <div data-hook="admin_product_form_sellable_on_platform">
6
+ <%= f.field_container :sellable_on_platform do %>
7
+ <%= f.check_box :sellable_on_platform %>
8
+ <%= f.label :sellable_on_platform, 'Also sell on BookMe+' %>
9
+ <%= f.error_message_on :sales_channel %>
10
+ <% end %>
11
+ </div>
12
+ <% end %>
@@ -4,7 +4,10 @@ module Spree
4
4
  module TaxonSerializerDecorator
5
5
  def self.prepended(base)
6
6
  base.has_many :vendors, serializer: ::Spree::V2::Storefront::VendorSerializer
7
- base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer
7
+ # The app lists an event section's tickets from this relation, so products a tenant keeps off BookMe+ are dropped here.
8
+ base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer do |taxon|
9
+ taxon.visible_products.sellable_on(:platform)
10
+ end
8
11
  base.has_many :places, serializer: ::Spree::V2::Storefront::PlaceSerializer
9
12
 
10
13
  base.has_one :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer
@@ -4,13 +4,13 @@ module SpreeCmCommissioner
4
4
  # MaintenanceTasks::CacheInvalidation sends. Every record of a model goes through InvalidateAll.
5
5
  #
6
6
  # Only the record's own storefront is cleared, as InvalidateAll does: its tenant's API
7
- # (/api/v2/tenant/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise. Each path
8
- # is billed, and the other storefront's copy, if anything ever fetched one, expires within a CDN
9
- # TTL anyway.
7
+ # (/api/v2/<tenant slug>/ paths) when it belongs to a tenant, the BookMe+ storefront otherwise.
8
+ # Each path is billed, and the other storefront's copy, if anything ever fetched one, expires
9
+ # within a CDN TTL anyway. Legacy /api/v2/tenant/ paths are never CDN-cached, so never cleared.
10
10
  class Invalidate
11
11
  prepend ::Spree::ServiceModule::Base
12
12
 
13
- TENANT_PREFIX = '/api/v2/tenant/'.freeze
13
+ TENANT_PREFIX = '/api/v2/:tenant_slug/'.freeze
14
14
 
15
15
  SUPPORTED_MODELS = {
16
16
  'SpreeCmCommissioner::HomepageSection' => SpreeCmCommissioner::HomepageSection,
@@ -30,7 +30,7 @@ module SpreeCmCommissioner
30
30
  # path per BIT_SEGMENT key.
31
31
  HOMEPAGE_SECTION_PATTERNS = [
32
32
  '/api/v2/storefront/homepage*',
33
- '/api/v2/tenant/homepage*'
33
+ '/api/v2/:tenant_slug/homepage*'
34
34
  ].freeze
35
35
 
36
36
  HOMEPAGE_BACKGROUND_PATTERNS = ['/api/v2/storefront/homepage*'].freeze
@@ -42,12 +42,13 @@ module SpreeCmCommissioner
42
42
  # which vendors are affected.
43
43
  #
44
44
  # vendor_id is a query param, so the trailing `*` is what clears every
45
- # vendor's cached variant of this path in one go.
45
+ # vendor's cached variant of this path in one go. CloudFront allows `*` only at the end, so the
46
+ # tenant path expands to one path per tenant.
46
47
  #
47
48
  # Payment methods change a handful of times a year, so a broad invalidation
48
49
  # is cheap.
49
50
  PAYMENT_METHOD_PATTERNS = [
50
- '/api/v2/tenant/payment_methods*',
51
+ '/api/v2/:tenant_slug/payment_methods*',
51
52
  '/api/v2/storefront/payment_methods*'
52
53
  ].freeze
53
54
 
@@ -60,7 +61,7 @@ module SpreeCmCommissioner
60
61
  invalidate_single_record(model_class: model_class, id: id)
61
62
  end
62
63
 
63
- # eg. 'Spree::Product', 123 => ['/api/v2/tenant/products/123', '/api/v2/tenant/products/slug-of-product', ...]
64
+ # eg. 'Spree::Product', 123 => ['/api/v2/hangmeas/products/123*', '/api/v2/hangmeas/products/slug-of-product*']
64
65
  def invalidate_single_record(model_class:, id:)
65
66
  return failure(nil, "#{model_class.name} ID is required") if id.blank?
66
67
 
@@ -79,17 +80,27 @@ module SpreeCmCommissioner
79
80
  # The record's own class first (a Show found as a Spree::Taxon keeps its show paths), then the
80
81
  # model asked for, since STI subclasses such as Spree::Gateway::Payway have no entry of their own.
81
82
  patterns = single_api_pattern[record.class.name] || single_api_pattern[model_class.name]
82
- return patterns if patterns.equal?(PAYMENT_METHOD_PATTERNS)
83
+ return expand_tenant_slug(patterns, SpreeCmCommissioner::Tenant.pluck(:slug)) if patterns.equal?(PAYMENT_METHOD_PATTERNS)
83
84
 
84
85
  tenant = tenant_of(record)
85
86
  patterns.select { |pattern| pattern.start_with?(TENANT_PREFIX) == tenant.present? }.map do |pattern|
86
87
  pattern.gsub(/:(\w+)/) do
87
88
  key = Regexp.last_match(1)
88
- record.public_send(key).to_s
89
+ key == 'tenant_slug' ? tenant.slug : record.public_send(key).to_s
89
90
  end
90
91
  end
91
92
  end
92
93
 
94
+ # One copy of each `:tenant_slug` pattern per tenant; storefront patterns pass through as is.
95
+ def expand_tenant_slug(patterns, slugs)
96
+ slugs = slugs.compact_blank
97
+ patterns.flat_map do |pattern|
98
+ next [pattern] unless pattern.start_with?(TENANT_PREFIX)
99
+
100
+ slugs.map { |slug| pattern.sub(':tenant_slug', slug) }
101
+ end
102
+ end
103
+
93
104
  # A product, vendor or homepage section has its own tenant; a taxon, show or voting session has
94
105
  # its vendor's. Menus, seat layouts and homepage backgrounds are BookMe+ only.
95
106
  def tenant_of(record)
@@ -106,23 +117,23 @@ module SpreeCmCommissioner
106
117
  # The app reads menus from homepage_data; nothing calls /menus any more.
107
118
  'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
108
119
  'Spree::Taxon' => [
109
- '/api/v2/tenant/taxons/:id*',
110
- '/api/v2/tenant/taxons/:permalink*',
120
+ '/api/v2/:tenant_slug/taxons/:id*',
121
+ '/api/v2/:tenant_slug/taxons/:permalink*',
111
122
  '/api/v2/storefront/taxons/:id*',
112
123
  '/api/v2/storefront/taxons/:permalink*'
113
124
  ],
114
125
  'Spree::Product' => [
115
- '/api/v2/tenant/products/:id*',
116
- '/api/v2/tenant/products/:slug*',
126
+ '/api/v2/:tenant_slug/products/:id*',
127
+ '/api/v2/:tenant_slug/products/:slug*',
117
128
  '/api/v2/storefront/products/:id*',
118
129
  '/api/v2/storefront/products/:slug*'
119
130
  ],
120
- 'SpreeCmCommissioner::Show' => ['/api/v2/tenant/shows/:slug*'],
131
+ 'SpreeCmCommissioner::Show' => ['/api/v2/:tenant_slug/shows/:slug*'],
121
132
  'SpreeCmCommissioner::VotingSession' => [
122
- '/api/v2/tenant/voting_sessions/:id/voting_contestants*'
133
+ '/api/v2/:tenant_slug/voting_sessions/:id/voting_contestants*'
123
134
  ],
124
135
  'Spree::Vendor' => [
125
- '/api/v2/tenant/vendors/:id*',
136
+ '/api/v2/:tenant_slug/vendors/:id*',
126
137
  '/api/v2/storefront/vendors/:id*',
127
138
  '/api/v2/storefront/accommodations/:id*',
128
139
  '/api/v2/storefront/accommodations/:slug*'
@@ -4,10 +4,7 @@ module SpreeCmCommissioner
4
4
  # serve them, which MaintenanceTasks::CacheInvalidation sends, as for Invalidate. `tenant` is
5
5
  # required so a clear-all never pays for both namespaces by accident: nil is the BookMe+
6
6
  # storefront (/api/v2/storefront/ paths), a SpreeCmCommissioner::Tenant is that tenant's API
7
- # (/api/v2/tenant/ paths).
8
- #
9
- # Tenant APIs take the tenant from the OAuth token, not the URL, so every tenant shares one path
10
- # today; taking the record keeps callers the same if tenant paths ever carry it.
7
+ # (/api/v2/<tenant slug>/ paths).
11
8
  #
12
9
  # eg. InvalidateAll.call(model: 'SpreeCmCommissioner::HomepageSection', tenant: nil)
13
10
  class InvalidateAll
@@ -20,11 +17,11 @@ module SpreeCmCommissioner
20
17
  'Spree::Menu' => ['/api/v2/storefront/homepage_data*'],
21
18
  'Spree::Taxon' => ['/api/v2/storefront/taxons/*'],
22
19
  'Spree::Product' => [
23
- '/api/v2/tenant/products/*',
20
+ '/api/v2/:tenant_slug/products/*',
24
21
  '/api/v2/storefront/products/*'
25
22
  ],
26
23
  'Spree::Vendor' => [
27
- '/api/v2/tenant/vendors/*',
24
+ '/api/v2/:tenant_slug/vendors/*',
28
25
  '/api/v2/storefront/vendors/*',
29
26
  '/api/v2/storefront/accommodations/*'
30
27
  ],
@@ -44,7 +41,8 @@ module SpreeCmCommissioner
44
41
 
45
42
  # Side-effect free, so the clear-cache dialog can show what a clear would send.
46
43
  def patterns_for(model, tenant:)
47
- API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
44
+ patterns = API_PATTERNS.fetch(model, []).select { |pattern| pattern.start_with?(Invalidate::TENANT_PREFIX) == tenant.present? }
45
+ tenant ? Invalidate.new.expand_tenant_slug(patterns, [tenant.slug]) : patterns
48
46
  end
49
47
 
50
48
  # updated_at keys Spree's serializer and collection caches, so only this storefront's records
@@ -72,7 +72,8 @@ module SpreeCmCommissioner
72
72
  device_id: options[:device_id],
73
73
  last_ip_address: options[:last_ip_address]
74
74
  )
75
- line_items_params.each { |line_item_params| build_line_item!(order, store, line_item_params) }
75
+ sellable_variants = store.variants.joins(:product).merge(Spree::Product.sellable_on(Spree::Product.sales_channel_for(tenant)))
76
+ line_items_params.each { |line_item_params| build_line_item!(order, sellable_variants, line_item_params) }
76
77
 
77
78
  ActiveRecord::Base.transaction do
78
79
  raise StandardError, order_error_message(order) unless order.save
@@ -105,8 +106,10 @@ module SpreeCmCommissioner
105
106
 
106
107
  # Builds (unsaved) an order.line_items member — persisted together with the order via
107
108
  # order.save's cascading autosave of new has_many records, same as TransitOrder::Create.
108
- def build_line_item!(order, store, line_item_params)
109
- variant = store.variants.find(line_item_params[:variant_id])
109
+ def build_line_item!(order, sellable_variants, line_item_params)
110
+ variant = sellable_variants.find_by(id: line_item_params[:variant_id])
111
+ raise StandardError, I18n.t('spree_cm_commissioner.draft_order.variant_not_available') if variant.nil?
112
+
110
113
  options = (line_item_params[:options] || {}).to_h.with_indifferent_access
111
114
  permitted_opts = ::Spree::PermittedAttributes.line_item_attributes.flatten.index_with { |attribute| options[attribute] }
112
115
  opts = permitted_opts.merge(currency: order.currency).compact
@@ -35,10 +35,10 @@ module SpreeCmCommissioner
35
35
  return success(:skipped) unless firestore_available?
36
36
 
37
37
  products = active_products(taxon).to_a
38
- return success(:skipped) if products.empty?
39
-
40
- sums = aggregate_quantities(products)
41
38
  existing_products = existing_products_data(taxon)
39
+ return success(:skipped) if products.empty? && existing_products.empty?
40
+
41
+ sums = products.empty? ? {} : aggregate_quantities(products)
42
42
  now = Time.zone.now
43
43
 
44
44
  changed = {}
@@ -51,6 +51,8 @@ module SpreeCmCommissioner
51
51
  changed[product.id.to_s] = build_entry(product, status, quantities, existing_products, now)
52
52
  end
53
53
 
54
+ prune_stale_entries(changed, products, existing_products)
55
+
54
56
  return success(:unchanged) if changed.empty?
55
57
 
56
58
  event_document(taxon).set({ products: changed, server_time: now }, merge: true)
@@ -66,6 +68,20 @@ module SpreeCmCommissioner
66
68
 
67
69
  private
68
70
 
71
+ # The write below is always `merge: true`, which only ever adds or updates map entries — it
72
+ # never drops one. So a product that stops being active (unlisted/unlinked from its section,
73
+ # discontinued, status flipped off — see `active_products`) would otherwise keep its last
74
+ # stock status in Firestore forever, and the app would go on rendering the removed product.
75
+ # Deleting the `products.<id>` field (nested FieldValue.delete, carried in the same merge set)
76
+ # is what actually removes it. existing_products is read even when no product is active, so an
77
+ # event whose every ticket got unlisted still gets its stale entries cleared.
78
+ def prune_stale_entries(changed, products, existing_products)
79
+ active_ids = products.map { |product| product.id.to_s }
80
+ (existing_products.keys - active_ids).each do |stale_id|
81
+ changed[stale_id] = firestore.field_delete
82
+ end
83
+ end
84
+
69
85
  # `quantities` is the [available, on_hold, locked, locked_on_hold] tuple from
70
86
  # `aggregate_quantities`. `previous_status` is read back from what's already stored, not
71
87
  # recomputed — it's the bucket this product was in on the last publish, if any.
@@ -117,6 +133,16 @@ module SpreeCmCommissioner
117
133
  # true. Caught via a real event whose products all had available_on: nil: the poll job was
118
134
  # returning :skipped for it on every cycle, so Firestore just sat frozen with stale data.
119
135
  #
136
+ # `preview: false` for the same reason unlisted products are excluded: events/{event_id} is
137
+ # publicly readable (no auth on the Firestore rule), so a preview product — active but held
138
+ # back from the public pending a PreviewRole, product_decorator.rb `publicly_available` — must
139
+ # not leak a live stock-status entry there before launch.
140
+ #
141
+ # `offline: false` because an offline product is deliberately not inventory-monitored (the
142
+ # admin form labels it "not monitored in Inventory Monitor", and InventoryItems finders like
143
+ # RecentlyChangedFinder already filter it out) — its Redis pools aren't maintained, so any
144
+ # stock status published for it would be meaningless.
145
+ #
120
146
  # `where(status: 'active')` instead of core's `Product.active` scope: that scope also joins
121
147
  # variants/prices in to filter by currency (`with_currency`), and `spree_variants` carries its
122
148
  # own `available_on`/`discontinue_on` columns — once that join is present, the raw,
@@ -133,7 +159,7 @@ module SpreeCmCommissioner
133
159
  # is excluded here too rather than still getting a live stock-status entry in Firestore.
134
160
  def active_products(taxon)
135
161
  now = Time.zone.now
136
- taxon.event_products.ecommerce.where(status: 'active')
162
+ taxon.event_products.ecommerce.where(status: 'active', preview: false, offline: false)
137
163
  .where('available_on IS NULL OR available_on <= ?', now)
138
164
  .where('discontinue_on IS NULL OR discontinue_on > ?', now)
139
165
  .where(id: taxon.children_classifications.where(visible: true).select(:product_id))
@@ -41,31 +41,53 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
41
41
  available_seats = external_trip.available_seats
42
42
  return if available_seats.nil? # unknown, not zero - don't zero out real inventory
43
43
 
44
- current_stock = stock_item.count_on_hand.to_i
45
- difference = available_seats.to_i - current_stock
44
+ sync_stock_item(stock_item, vehicle_capacity(external_trip, available_seats.to_i))
45
+ sync_inventory_item_for_date(stock_item.variant, external_trip, available_seats.to_i)
46
+ end
47
+
48
+ # count_on_hand is one number shared by every date and seeds new inventory items, so it holds the vehicle's seats.
49
+ # A date's own seats would rewrite it (and enqueue VendorJob) on most polls, and could seed new dates with 0.
50
+ def vehicle_capacity(external_trip, available_seats)
51
+ number_of_seats = external_trip.number_of_seats.to_i
52
+
53
+ number_of_seats.positive? ? number_of_seats : available_seats
54
+ end
55
+
56
+ def sync_stock_item(stock_item, seats)
57
+ difference = seats - stock_item.count_on_hand.to_i
46
58
  return if difference.zero?
47
59
 
48
60
  @sync_result.track(:stock_item, stock_item) do
49
61
  stock_item.adjust_count_on_hand(difference)
50
62
  @sync_result.increment_metric(:inventory, :adjusted, difference.abs)
51
63
  end
52
-
53
- adjust_inventory_item_for_date(stock_item.variant, external_trip, difference)
54
64
  end
55
65
 
56
66
  # StockItem.count_on_hand isn't date-aware and isn't read for real availability checks
57
- # (see docs/concepts/inventory-system.md) - apply the same delta to the specific date's
58
- # InventoryItem so ongoing BookMeBus availability changes actually reach bookable inventory.
59
- # If it doesn't exist yet, PermanentInventoryItemsGeneratorJob will seed it later from
60
- # count_on_hand, so there's nothing to adjust yet.
61
- def adjust_inventory_item_for_date(variant, external_trip, difference)
67
+ # (see docs/concepts/inventory-system.md), so each date's InventoryItem is set to that date's own seats_remaining,
68
+ # never moved by its difference from another date's seats: an item seeded from a different count (0 after a sold-out
69
+ # first date) would go negative and fail the whole trip's sync. If the item doesn't exist yet, the permanent items
70
+ # job seeds it later from count_on_hand.
71
+ def sync_inventory_item_for_date(variant, external_trip, available_seats)
62
72
  trip_date = parse_trip_date(external_trip.on_date)
63
73
  return unless trip_date
64
74
 
65
75
  inventory_item = variant.inventory_items.find_by(inventory_date: trip_date)
66
- return unless inventory_item
76
+ return if inventory_item.nil? || inventory_item.quantity_available == seats_for_sale(inventory_item, available_seats)
77
+
78
+ inventory_item.with_lock do
79
+ SpreeCmCommissioner::InventoryItems::AdjustAvailableQuantity.call!(
80
+ inventory_item: inventory_item,
81
+ delta: seats_for_sale(inventory_item, available_seats) - inventory_item.quantity_available,
82
+ caller_source: self.class.name,
83
+ lock: false
84
+ )
85
+ end
86
+ end
67
87
 
68
- inventory_item.adjust_quantity!(difference)
88
+ # BookMeBus doesn't know about seats an operator withheld here, so its count still includes them.
89
+ def seats_for_sale(inventory_item, available_seats)
90
+ [available_seats - inventory_item.quantity_locked - inventory_item.locked_count, 0].max
69
91
  end
70
92
 
71
93
  def parse_trip_date(date_string)
@@ -33,7 +33,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
33
33
  status: :active,
34
34
  vendor: @vendor,
35
35
  product_type: :transit,
36
- available_on: Time.current,
36
+ # Keeps the first value: a fresh time counts as a change, so every poll would save the product and purge its API cache.
37
+ available_on: product.available_on || Time.current,
37
38
  shipping_category: default_shipping_category,
38
39
  stores: [Spree::Store.default],
39
40
  option_types: [seat_type_option].compact,
@@ -42,7 +43,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
42
43
  tracker.save_if_changed!(product)
43
44
  end
44
45
 
45
- mapping.mark_as_active!(external_payload: { trip_id: external_trip.trip_id })
46
+ mapping.confirm_active!(external_payload: { trip_id: external_trip.trip_id })
46
47
  product
47
48
  end
48
49
 
@@ -135,11 +135,12 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
135
135
  tracker.save_if_changed!(trip_stop)
136
136
  end
137
137
 
138
- mapping.mark_as_active!(
138
+ mapping.confirm_active!(
139
139
  external_payload: stop_time_data.merge(
140
140
  'stop_name' => stop_data['name'],
141
141
  'location_name' => location_data['name']
142
- )
142
+ ),
143
+ ignoring: SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip::VOLATILE_PAYLOAD_KEYS
143
144
  )
144
145
 
145
146
  mapping
@@ -177,7 +178,10 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
177
178
  tracker.save_if_changed!(stop_vendor_place)
178
179
  end
179
180
 
180
- mapping.mark_as_active!(external_payload: external_stop.to_h.merge('place_type' => external_stop.vendor_place_type.to_s))
181
+ mapping.confirm_active!(
182
+ external_payload: external_stop.to_h.merge('place_type' => external_stop.vendor_place_type.to_s),
183
+ ignoring: SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip::VOLATILE_PAYLOAD_KEYS
184
+ )
181
185
 
182
186
  # Return the appropriate place based on stop type:
183
187
  # - BRANCHES: return location_vendor_place.place (the city/location)